uniweb 0.60.0 → 0.62.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.60.0",
3
+ "version": "0.62.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/core": "^0.29.6",
46
- "@uniweb/kit": "^0.19.3",
47
- "@uniweb/runtime": "^0.27.2",
44
+ "@uniweb/core": "^0.29.8",
48
45
  "@uniweb/schemas": "^0.3.3",
49
- "@uniweb/semantic-parser": "^1.4.1"
46
+ "@uniweb/semantic-parser": "^1.4.1",
47
+ "@uniweb/kit": "^0.19.4",
48
+ "@uniweb/runtime": "^0.27.4",
49
+ "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.56.0",
53
52
  "@uniweb/content-reader": "^1.2.5",
54
- "@uniweb/semantic-parser": "^1.4.1"
53
+ "@uniweb/semantic-parser": "^1.4.1",
54
+ "@uniweb/build": "^0.58.0"
55
55
  },
56
56
  "peerDependenciesMeta": {
57
57
  "@uniweb/build": {
@@ -129,7 +129,7 @@ my-project/
129
129
  └── pnpm-workspace.yaml
130
130
  ```
131
131
 
132
- A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site`.
132
+ A site is pure content. A foundation is the site's source code — that's why it lives in `src/`. The foundation's `package.json::name` is `src`, symmetric with `site` — a **workspace** name, how the site depends on it. What the foundation **registers as** (`@org/<name>`, the name sites pin) is `name` in its `main.js`, which `uniweb create` sets to the project's name. **`src` and `foundation` are never a foundation's name** — `uniweb register` refuses them, because every project in an org would register the same one.
133
133
 
134
134
  - **Foundation** (`src/`): React components. Those in `sections/` and `layouts/` are *section types* — selectable by authors via `type:`, or used for layout areas. Everything in `components/` and `utils/` is ordinary React and JS: the developer's workbench, not visible to authors.
135
135
  - **Site** (`site/`): markdown content and configuration, plus optional records of structured content and the queries that reach them or an external data source.
@@ -845,26 +845,26 @@ It is a small database, not a page tree. Three things, deliberately separate:
845
845
  ```
846
846
  site/
847
847
  ├── pages/
848
- ├── entities/ # your stored things. The folder names their data schema
849
- │ ├── article/
848
+ ├── records/ # your site's records — every file in a schema folder is one
849
+ │ ├── article/ # the folder names their data schema
850
850
  │ │ ├── getting-started.md
851
851
  │ │ └── design-tips.md
852
- │ └── person/
853
- │ └── alice.yml
854
- ├── records.yml # what is PUBLISHED
852
+ │ ├── person/
853
+ │ │ └── alice.yml
854
+ │ └── folder.yml # optional: sorts records into folders
855
855
  ├── queries.yml # how content is REACHED
856
856
  └── site.yml
857
857
  ```
858
858
 
859
- **`entities/{schema}/` — the pool.** The folder names the data schema and nothing else:
859
+ **`records/{schema}/` — the site's records.** Putting a file here is what makes it a record; nothing else lists it. The folder names the data schema and nothing else:
860
860
 
861
861
  | on disk | schema |
862
862
  |---|---|
863
- | `entities/article/…` | `@/article` — your foundation's own |
864
- | `entities/std/person/…` | `@std/person` — the shared standard set |
865
- | `entities/acme/project/…` | `@acme/project` — an org's |
863
+ | `records/article/…` | `@/article` — your foundation's own |
864
+ | `records/std/person/…` | `@std/person` — the shared standard set |
865
+ | `records/acme/project/…` | `@acme/project` — an org's |
866
866
 
867
- Keep those folders flat: `entities/article/design-tips.md` works; `entities/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records.yml` instead.
867
+ Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records/folder.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
868
868
 
869
869
  **Four formats, one shape.** All of these produce the same records at runtime:
870
870
 
@@ -877,32 +877,22 @@ Keep those folders flat: `entities/article/design-tips.md` works; `entities/arti
877
877
 
878
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`.
879
879
 
880
- Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `description`, `published`, `author` — plus any fields your content needs (`price`, `role`, `order`). Images can sit beside the item file and be referenced with `./`. `published: false` hides an item without deleting it; items with no `published` field are included.
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
- **`records.yml` — listing an entity is what publishes it.** A file in `entities/` exists; listing it here makes it a record. Anything you leave out is a draft — no flag to set. **The common case is three lines:**
882
+ **`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:
883
883
 
884
884
  ```yaml
885
- - article/*.md
886
- - person/*.yml
887
- ```
888
-
889
- A bare string is a path under `entities/`, naming one file or matching many.
890
-
891
- ⚠️ **An empty `records.yml` is not the same as having none.** With no file, a build publishes every entity in `entities/`, and `uniweb push` leaves the published set alone. An empty file means "nothing is published": a build publishes none, and a push REMOVES what is published. The CLI asks before it does that.
892
-
893
- **Structure is for querying, not for navigation.** Add a `folder:` only when a query needs to ask for a *slice* of the pool rather than all of it — most sites never do:
894
-
895
- ```yaml
896
- - article/2026-*.md
897
885
  - folder: archive
898
886
  label: The Archive
899
887
  records:
900
888
  - article/2025-*.md
901
889
  ```
902
890
 
903
- A query reads one branch with `scope: archive` — the folder and everything inside it (the older `where: { path: { under: … } }` is refused by the build). An entity belongs to one folder; if you want a computed subset, that is a query, not a second placement.
891
+ A path under a folder is relative to `records/`, naming one file or matching many. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
904
892
 
905
- **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the published records of that schema are its rows:
893
+ ⚠️ **With a backend, `records/` is what `uniweb push` sends** — every record in it. A site with no `records/` at all leaves the backend's records alone; an *empty* `records/` removes them, and the CLI asks before it does. Records pushed to a backend are served once the site is published.
894
+
895
+ **`queries.yml` — how content is reached.** A bare map of name → query. A query names a schema and the site's records of that schema are its rows:
906
896
 
907
897
  ```yaml
908
898
  recent:
@@ -912,7 +902,7 @@ recent:
912
902
 
913
903
  team:
914
904
  schema: '@std/person'
915
- where: { published: { ne: false } } # a predicate — see authoring/predicates.md
905
+ where: { active: true } # a predicate — see authoring/predicates.md
916
906
  ```
917
907
 
918
908
  You can keep the same declarations under `queries:` in `site.yml` instead, if you would rather have one file.
@@ -944,7 +934,7 @@ pages/blog/
944
934
  └── article.md # just `type: Article` — the record arrives automatically
945
935
  ```
946
936
 
947
- `entities/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
937
+ `records/article/design-tips.md` becomes `/blog/design-tips`. The section inside `[slug]/` needs no special markdown — the matched record is delivered to it. Generated pages are excluded from navigation menus.
948
938
 
949
939
  **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.
950
940
 
@@ -952,7 +942,7 @@ pages/blog/
952
942
 
953
943
  > **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.
954
944
 
955
- **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.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`.
945
+ **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`.
956
946
 
957
947
  **Two options for bigger sets:**
958
948
 
@@ -1016,7 +1006,7 @@ import LessonHeader from '../../components/LessonHeader' // ❌ breaks if you re
1016
1006
 
1017
1007
  Within the same directory, use normal relative imports (`./AIFeedbackCard`).
1018
1008
 
1019
- **Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the capabilities the foundation provides — `name`, `description`, `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. Everything here is read at render; the one thing a foundation declares that *isn't* — which host services it supports — lives in `package.json` instead (see [Declaring what your foundation supports](#declaring-what-your-foundation-supports)). Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in `dist/entry.js`; you never write that wrapper. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
1009
+ **Foundation entry (`main.js`).** A single `export default { … }` whose top-level keys are the foundation's identity — `name` (what it registers as, `@org/<name>`) and `description` — and the capabilities it provides — `defaultLayout`, `defaultSection`, `viewTransitions`, `props`, `defaultInsets`, `xref`, `outputs`, `handlers` — plus an optional named `vars` export. The capabilities are read at render; the one thing a foundation declares that *isn't* — which host services it supports — lives in `package.json` instead (see [Declaring what your foundation supports](#declaring-what-your-foundation-supports)). Section types and layouts are auto-discovered and merged in by `@uniweb/build`. The build wraps your default export under `default.capabilities` in `dist/entry.js`; you never write that wrapper. The one place it matters: when you import your **own** `main.js` from a component (e.g. a download button calling `compileDocument(website, { foundation })`), you get the bare default object — pass it through directly, Press handles both shapes.
1020
1010
 
1021
1011
  ### Props interface
1022
1012
 
@@ -1871,7 +1861,7 @@ A foundation can route a scope to a plain folder of schema files instead of a pa
1871
1861
  # pages/blog/page.yml
1872
1862
  fetch:
1873
1863
  query: recent
1874
- where: { published: true, tags: featured }
1864
+ where: { tags: featured }
1875
1865
  sort: date desc
1876
1866
  limit: 3
1877
1867
  ```
@@ -2604,7 +2594,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2604
2594
 
2605
2595
  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.
2606
2596
 
2607
- **Publishing vs registering.** Foundations on Uniweb Cloud live in the catalog as `@org/name@version`. 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. 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`.
2597
+ **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. 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`.
2608
2598
 
2609
2599
  ### Staying current
2610
2600
 
@@ -2640,10 +2630,11 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
2640
2630
 
2641
2631
  | Field | Where used | Default | Purpose |
2642
2632
  |---|---|---|---|
2643
- | `id` | `uniweb register` | bare segment of a scoped `name` | The foundation's registered id — the bare name in `@org/<id>`. Decoupled from `package.json::name` (a workspace concern), so renaming on the registry doesn't ripple through site dependencies. |
2644
- | `namespace` | `uniweb register` | none | Legacy explicit org-namespace override; equivalent to a scoped `package.json::name`. Rarely needed. |
2633
+ | `scope` | `uniweb register` | derived from your login, then saved here | The org the foundation registers under (`@acme`, or `acme`): a bare `main.js` name `marketing` registers as `@acme/marketing`. `--scope @org` overrides it for one run. |
2645
2634
  | `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
2646
2635
 
2636
+ **The foundation's name is not here — it is `name` in `main.js`** (else `package.json`'s `name`). ⛔ `uniweb.id` is no longer read: `uniweb register` refuses it and prints the `main.js` line to write instead.
2637
+
2647
2638
  **Runtime updates — handled for you.** Your foundation's code links against the runtime: it externalizes `react`, `react-dom`, `react-dom/server`, both JSX runtimes and `@uniweb/core`, and the runtime supplies all of them at load time. So a build is bound to *that* React and *that* core API.
2648
2639
 
2649
2640
  You don't declare anything for this. At build time your foundation records the `@uniweb/runtime` version it actually linked against (read from `node_modules`, not chosen) into `dist/runtime-pin.json`, and `uniweb register` sends it with your foundation, so the version you built against travels with it. **Whether two runtime versions are compatible is our determination, not a setting** — you have no way to know whether we changed anything your code can reach, so we don't ask you to guess.
@@ -2697,7 +2688,7 @@ Running `extract` before a build is the usual first mistake — it reads the com
2697
2688
 
2698
2689
  ```
2699
2690
  locales/freeform/es/pages/about/hero.md # by page route
2700
- locales/freeform/es/entities/article/x.md # records work too
2691
+ locales/freeform/es/records/article/x.md # records work too
2701
2692
  ```
2702
2693
 
2703
2694
  These are **body only — no frontmatter**; params and config still come from the source section. `uniweb i18n init-freeform es pages/about hero` creates one pre-filled and records a source hash, so `uniweb i18n status --freeform` can tell you when the original moved on (`update-hash` to acknowledge). `move`, `rename`, and `prune --freeform` keep them aligned when pages get reorganized.
@@ -7,9 +7,9 @@
7
7
  *
8
8
  * ## Where an asset lands, and why there are two answers
9
9
  *
10
- * - **The map knows it** (`assets.json`) — the file goes back to the path its
11
- * author wrote. That is the whole reason the map is committed: a fresh clone
12
- * restores `public/images/hero.png`, not a hash.
10
+ * - **The map knows it** (this backend's `assets` in `sync.json`) — the file goes
11
+ * back to the path its author wrote. That is the whole reason the map is
12
+ * committed: a fresh clone restores `public/images/hero.png`, not a hash.
13
13
  * - **The map does not know it** — this project has never held these bytes:
14
14
  * authored in the app, or pushed from a machine whose map entry has not
15
15
  * arrived. There is no local path to guess, so it lands at a **generic,
@@ -21,8 +21,8 @@
21
21
  * Identity rides beside the reference (`sync-package.js` stamps `assetId` next
22
22
  * to the serve URL), so the address is already on the node. Composing one would
23
23
  * mean holding the host's route layout — the coupling deleting `buildAssetUrl`
24
- * removed from this CLI, and the reason `assets.json` stores no URL either. An
25
- * id with no URL beside it is skipped, not guessed at.
24
+ * removed from this CLI, and the reason the asset map stores no URL either (only a
25
+ * fingerprint of one). An id with no URL beside it is skipped, not guessed at.
26
26
  *
27
27
  * ## ⛔ A failed download is a WARNING, never a failed pull
28
28
  *
@@ -36,9 +36,15 @@ import { readFileSync } from 'node:fs'
36
36
  import { join } from 'node:path'
37
37
  import { execFileSync } from 'node:child_process'
38
38
 
39
- import { detectFoundationType, isExtensionUrl } from '@uniweb/build'
39
+ import {
40
+ detectFoundationType,
41
+ isExtensionUrl,
42
+ readFoundationName,
43
+ checkFoundationName
44
+ } from '@uniweb/build'
40
45
  import { computeFoundationDigest } from '../utils/code-upload.js'
41
46
  import { isNonInteractive } from '../utils/interactive.js'
47
+ import { publishScope } from '../utils/registry-orgs.js'
42
48
  import { compareSemverPrecedence } from '../utils/semver-precedence.js'
43
49
 
44
50
  /**
@@ -47,9 +53,12 @@ import { compareSemverPrecedence } from '../utils/semver-precedence.js'
47
53
  * already has it; nothing to do). Uses the SAME resolver the build uses
48
54
  * (`detectFoundationType`), so "which foundation" never drifts between them.
49
55
  *
56
+ * Its catalog name is `foundationScopedName(dir)` — a separate, async read, because
57
+ * the name lives in `main.js`, which is loaded rather than parsed.
58
+ *
50
59
  * @param {string} siteDir
51
60
  * @param {object} siteYml - parsed site.yml
52
- * @returns {{ dir: string, scopedName: string|null, version: string|null }|null}
61
+ * @returns {{ dir: string, version: string|null }|null}
53
62
  */
54
63
  export function resolveLocalFoundation(siteDir, siteYml) {
55
64
  const decl = siteYml?.foundation
@@ -63,11 +72,7 @@ export function resolveLocalFoundation(siteDir, siteYml) {
63
72
  return null
64
73
  }
65
74
  if (!info || info.type !== 'local' || !info.path) return null
66
- return {
67
- dir: info.path,
68
- scopedName: foundationScopedName(info.path),
69
- version: readPkgField(info.path, 'version')
70
- }
75
+ return { dir: info.path, version: readPkgField(info.path, 'version') }
71
76
  }
72
77
 
73
78
  /**
@@ -81,7 +86,7 @@ export function resolveLocalFoundation(siteDir, siteYml) {
81
86
  *
82
87
  * @param {string} siteDir
83
88
  * @param {object} siteYml - parsed site.yml
84
- * @returns {Array<{ decl: string, dir: string, scopedName: string|null, version: string|null }>}
89
+ * @returns {Array<{ decl: string, dir: string, version: string|null }>}
85
90
  * `decl` is the authored declaration, which is the wire entry's `$id` — the key
86
91
  * publish stamps the pinned ref back onto.
87
92
  */
@@ -106,37 +111,43 @@ export function resolveLocalExtensions(siteDir, siteYml) {
106
111
  continue
107
112
  }
108
113
  if (!info || info.type !== 'local' || !info.path) continue
109
- out.push({
110
- decl,
111
- dir: info.path,
112
- scopedName: foundationScopedName(info.path),
113
- version: readPkgField(info.path, 'version')
114
- })
114
+ out.push({ decl, dir: info.path, version: readPkgField(info.path, 'version') })
115
115
  }
116
116
  return out
117
117
  }
118
118
 
119
- // The foundation's scoped catalog name (`@org/name`) from its package.json — an
120
- // already-scoped name, else `uniweb.scope` + a bare one. Null when neither
121
- // yields a scoped name (then we can't look up the registered version, so the
122
- // caller treats the foundation as "release it and let register pick the scope").
123
- //
124
- // ⛔ The name is `uniweb.id` when set, exactly as the build reads it for the
125
- // schema that `register` submits (`build/src/schema.js`). Reading `name` alone —
126
- // as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
127
- // the catalog does not have, so every push re-released it, and pinned the site
128
- // to that same wrong name.
129
- function foundationScopedName(dir) {
119
+ /**
120
+ * The foundation's scoped catalog name (`@org/name`): its name — `main.js`'s
121
+ * `name`, else package.json's, the one rule the build reads for the schema
122
+ * `register` submits (`readFoundationName`) — scoped by `uniweb.scope` unless
123
+ * already scoped. Null when no registrable scoped name can be formed; the caller
124
+ * then treats the foundation as unreleased, and `register` — which it runs to
125
+ * release — is where a missing scope is derived and a missing name is asked for.
126
+ *
127
+ * ⛔ A NAME THAT CANNOT REGISTER IS NULL, not looked up. `src` and `foundation`
128
+ * name a folder (`checkFoundationName`); looking one up would find some other
129
+ * project's `@org/src` — the very collision refusing them prevents.
130
+ *
131
+ * ⛔ And the scope goes through `publishScope`, as `register`'s does: `uniweb.scope`
132
+ * may read `acme` as well as `@acme`, and until 2026-09-21 this joined it raw — so a
133
+ * bare one looked the foundation up as `acme/src` and pinned the site to that.
134
+ * *(This read package.json alone until 2026-09-21 — `uniweb.id`, else `name` — and
135
+ * so named a foundation differently from the build whenever `main.js` named it.)*
136
+ *
137
+ * @param {string} dir - the foundation package
138
+ * @returns {Promise<string|null>}
139
+ */
140
+ export async function foundationScopedName(dir) {
141
+ let name
130
142
  try {
131
- const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
132
- const name = pkg?.uniweb?.id || pkg?.name
133
- if (typeof name === 'string' && name.startsWith('@')) return name
134
- const scope = pkg?.uniweb?.scope
135
- if (scope && name) return `${String(scope).replace(/\/+$/, '')}/${name}`
136
- return null
143
+ ;({ name } = await readFoundationName(dir))
137
144
  } catch {
138
145
  return null
139
146
  }
147
+ if (checkFoundationName(name)) return null
148
+ if (name.startsWith('@')) return name
149
+ const scope = publishScope(readPkgField(dir, 'uniweb')?.scope)
150
+ return scope ? `${scope}/${name}` : null
140
151
  }
141
152
 
142
153
  function readPkgField(dir, field) {
@@ -220,7 +231,7 @@ export async function bringFoundationAlong({
220
231
  * it in the messages.
221
232
  *
222
233
  * @param {object} o
223
- * @param {{dir: string, scopedName: string|null, version: string|null}} o.local
234
+ * @param {{dir: string, version: string|null}} o.local
224
235
  * @param {'foundation'|'extension'} o.kind
225
236
  * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null }>}
226
237
  */
@@ -236,9 +247,10 @@ async function bringLocalCodeAlong({
236
247
  verb = 'publish'
237
248
  }) {
238
249
  const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
250
+ const scopedName = await foundationScopedName(local.dir)
239
251
  const label =
240
- local.scopedName || local.version
241
- ? `${local.scopedName || kind}${local.version ? `@${local.version}` : ''}`
252
+ scopedName || local.version
253
+ ? `${scopedName || kind}${local.version ? `@${local.version}` : ''}`
242
254
  : `the local ${kind}`
243
255
  const skipPrompts =
244
256
  args.includes('--yes') ||
@@ -261,10 +273,11 @@ async function bringLocalCodeAlong({
261
273
  const noRelease = args.includes('--no-release')
262
274
 
263
275
  // The pinned ref to stamp on the pushed site — read at RETURN time (after any
264
- // release), so it reflects the released version + the scope register derived.
265
- // null when no scoped ref can be formed (then the site.yml ref is forwarded).
266
- const pinnedRef = () => {
267
- const s = foundationScopedName(local.dir)
276
+ // release), so it reflects the released version, the scope register derived and
277
+ // the name it asked for. null when no scoped ref can be formed (then the site.yml
278
+ // ref is forwarded).
279
+ const pinnedRef = async () => {
280
+ const s = await foundationScopedName(local.dir)
268
281
  const v = readPkgField(local.dir, 'version')
269
282
  return s && v ? `${s}@${v}` : null
270
283
  }
@@ -274,8 +287,8 @@ async function bringLocalCodeAlong({
274
287
  // so on a bumped-but-unreleased foundation it names a version nobody can serve. Any
275
288
  // branch that skips a release must bind to this instead.
276
289
  const registeredRef = (reg) =>
277
- local.scopedName && reg?.latest_version
278
- ? `${local.scopedName}@${reg.latest_version}`
290
+ scopedName && reg?.latest_version
291
+ ? `${scopedName}@${reg.latest_version}`
279
292
  : null
280
293
 
281
294
  // Dry-run reports the intent WITHOUT touching the network — it must not force
@@ -285,24 +298,23 @@ async function bringLocalCodeAlong({
285
298
  `${Kind} : ${label} — local; would release if changed or not yet registered`
286
299
  )
287
300
  // The ref still comes back where one can be formed: it is read from the
288
- // foundation's own package.json, so it costs no network, and an offline preview
289
- // that omitted it would emit a document the real run would not — the one thing
290
- // `-o` exists to avoid.
301
+ // foundation's own files, so it costs no network, and an offline preview that
302
+ // omitted it would emit a document the real run would not — the one thing `-o`
303
+ // exists to avoid.
291
304
  //
292
305
  // ⚠️ It is null for a foundation that has NEVER been registered and carries no
293
- // scope (a freshly scaffolded `name: "src"`), because the scope is what
294
- // `register` writes back (`writePkgScope`). So the preview shows the authored
295
- // value there, and the first real push — which releases, and so acquires the
296
- // scope — sends the pinned ref instead. That gap is unavoidable offline: before
297
- // the first release there is no registered name to name.
298
- return { released: false, proceed: true, ref: pinnedRef() }
306
+ // scope (a fresh scaffold), because the scope is what `register` writes back
307
+ // (`writePkgScope`) — and for one with no name of its own (`src`), which
308
+ // `register` asks for. So the preview shows the authored value there, and the
309
+ // first real push — which releases, and so acquires both — sends the pinned ref
310
+ // instead. That gap is unavoidable offline: before the first release there is
311
+ // no registered name to name.
312
+ return { released: false, proceed: true, ref: await pinnedRef() }
299
313
  }
300
314
 
301
315
  // Ask the catalog what it has. Null → not registered (or the backend can't
302
316
  // answer / no scoped name to look up) → release.
303
- const reg = local.scopedName
304
- ? await client.readFoundationLatest(local.scopedName)
305
- : null
317
+ const reg = scopedName ? await client.readFoundationLatest(scopedName) : null
306
318
 
307
319
  if (!reg) {
308
320
  // ⛔ Nothing to bind to. Releasing anyway would be the opposite of what was asked,
@@ -321,7 +333,7 @@ async function bringLocalCodeAlong({
321
333
  return {
322
334
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
323
335
  proceed: true,
324
- ref: pinnedRef()
336
+ ref: await pinnedRef()
325
337
  }
326
338
  }
327
339
 
@@ -334,7 +346,7 @@ async function bringLocalCodeAlong({
334
346
  say.dim(
335
347
  `${Kind} : ${label} — unchanged since release (digest matches); nothing to release.`
336
348
  )
337
- return { released: false, proceed: true, ref: pinnedRef() }
349
+ return { released: false, proceed: true, ref: await pinnedRef() }
338
350
  }
339
351
 
340
352
  // A different version locally → a new version to release.
@@ -364,7 +376,7 @@ async function bringLocalCodeAlong({
364
376
  return {
365
377
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
366
378
  proceed: true,
367
- ref: pinnedRef()
379
+ ref: await pinnedRef()
368
380
  }
369
381
  }
370
382
 
@@ -385,7 +397,7 @@ async function bringLocalCodeAlong({
385
397
  say.dim(
386
398
  'Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to release a change.'
387
399
  )
388
- return { released: false, proceed: true, ref: pinnedRef() }
400
+ return { released: false, proceed: true, ref: await pinnedRef() }
389
401
  }
390
402
  const reRelease = await confirm(
391
403
  `Re-release ${label} to be sure its code is current?`,
@@ -395,9 +407,9 @@ async function bringLocalCodeAlong({
395
407
  return {
396
408
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
397
409
  proceed: true,
398
- ref: pinnedRef()
410
+ ref: await pinnedRef()
399
411
  }
400
- return { released: false, proceed: true, ref: pinnedRef() }
412
+ return { released: false, proceed: true, ref: await pinnedRef() }
401
413
  }
402
414
 
403
415
  // Case 3 (§4): the code was edited but the version wasn't bumped. The
@@ -433,7 +445,7 @@ async function bringLocalCodeAlong({
433
445
  `Local ${label} differs from the registered ${reg.latest_version} and the version wasn't bumped — ` +
434
446
  `shipping against the registered code. Your local changes will NOT be live.`
435
447
  )
436
- return { released: false, proceed: true, ref: pinnedRef() }
448
+ return { released: false, proceed: true, ref: await pinnedRef() }
437
449
  }
438
450
 
439
451
  // 2. Nobody to ask. Refuse, and name both real options as runnable commands so
@@ -471,7 +483,7 @@ async function bringLocalCodeAlong({
471
483
  )
472
484
  return { released: false, proceed: false, ref: null }
473
485
  }
474
- return { released: false, proceed: true, ref: pinnedRef() }
486
+ return { released: false, proceed: true, ref: await pinnedRef() }
475
487
  }
476
488
 
477
489
  // Build the foundation so its dist/ can be fingerprinted. Idempotent — the
@@ -61,7 +61,8 @@ import { createHash } from 'node:crypto'
61
61
  * Rows are ordered by their serialized form and object keys sorted, so reordering
62
62
  * rows or keys in the file is not a change. It is not a request to move a line.
63
63
  *
64
- * @param {*} declared - the raw `site.yml::$services` / `$secrets` value
64
+ * @param {*} declared - the raw value: provisioned `services` / `secrets` rows (from
65
+ * `sync.json`), what the site has stored for them, or `site.yml::publishLanguages`
65
66
  * @returns {string|null} 16 hex chars, or null when undeclared
66
67
  */
67
68
  export function fingerprintDeclaration(declared) {
@@ -72,18 +72,18 @@ export async function uploadSiteMedia(
72
72
  warn?.(`local-media: upload failed for ${f.path} (HTTP ${f.status})`)
73
73
 
74
74
  const map = {}
75
- // The identity half, for `assets.json`. The plan returns an authoritative
76
- // `id`+`ext` for every entry INCLUDING a `present: true` dedup skip, so a
77
- // re-push of unchanged media still records identity without moving bytes —
78
- // which is what makes the committed map cheap to keep accurate.
75
+ // The identity half, for this backend's asset map in `sync.json`. The plan
76
+ // returns an authoritative `id`+`ext` for every entry INCLUDING a `present: true`
77
+ // dedup skip, so a re-push of unchanged media still records identity without
78
+ // moving bytes — which is what makes the committed map cheap to keep accurate.
79
79
  const ids = {}
80
80
  for (const ref of refs) {
81
81
  const entry = result.assetsByLocalUrl[ref]
82
82
  if (!entry) continue
83
83
  // `served` — a fingerprint of the address, never the address. It is how a pull
84
84
  // recognizes this asset where the reference is a bare string with no room for an
85
- // id beside it (`info.preview`, `seo.image`, a section param); `assets.json`'s
86
- // header says why a hash and not the URL.
85
+ // id beside it (`info.preview`, `seo.image`, a section param); the header of
86
+ // `@uniweb/build`'s asset-map.js says why a hash and not the URL.
87
87
  if (entry.id) {
88
88
  ids[ref] = {
89
89
  id: entry.id,