uniweb 0.61.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.61.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/core": "^0.29.7",
45
- "@uniweb/kit": "^0.19.4",
44
+ "@uniweb/core": "^0.29.8",
46
45
  "@uniweb/schemas": "^0.3.3",
47
46
  "@uniweb/semantic-parser": "^1.4.1",
48
- "@uniweb/runtime": "^0.27.3",
47
+ "@uniweb/kit": "^0.19.4",
48
+ "@uniweb/runtime": "^0.27.4",
49
49
  "@uniweb/content-writer": "^0.3.4"
50
50
  },
51
51
  "peerDependencies": {
52
- "@uniweb/build": "^0.57.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,13 +845,13 @@ It is a small database, not a page tree. Three things, deliberately separate:
845
845
  ```
846
846
  site/
847
847
  ├── pages/
848
- ├── records/ # your site's records — every file here is one
848
+ ├── records/ # your site's records — every file in a schema folder is one
849
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 # optional: sorts records into folders
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
  ```
@@ -864,7 +864,7 @@ site/
864
864
  | `records/std/person/…` | `@std/person` — the shared standard set |
865
865
  | `records/acme/project/…` | `@acme/project` — an org's |
866
866
 
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.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
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,9 +877,9 @@ Keep those folders flat: `records/article/design-tips.md` works; `records/articl
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`, `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 `uniweb push` stops at it, naming it. (`published: false`, an older spelling, is refused.)
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` — optional, and only for folders.** Every record sits at the top of the site's records folder unless `records.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:
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
885
  - folder: archive
@@ -888,7 +888,7 @@ Item frontmatter conventionally uses `title`, `date`, `tags`, `image`, `descript
888
888
  - article/2025-*.md
889
889
  ```
890
890
 
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 `records.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
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).
892
892
 
893
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
894
 
@@ -942,7 +942,7 @@ pages/blog/
942
942
 
943
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.
944
944
 
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.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`.
946
946
 
947
947
  **Two options for bigger sets:**
948
948
 
@@ -1006,7 +1006,7 @@ import LessonHeader from '../../components/LessonHeader' // ❌ breaks if you re
1006
1006
 
1007
1007
  Within the same directory, use normal relative imports (`./AIFeedbackCard`).
1008
1008
 
1009
- **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.
1010
1010
 
1011
1011
  ### Props interface
1012
1012
 
@@ -2594,7 +2594,7 @@ Foundations have their own free path too: `uniweb add ci --target foundation` pu
2594
2594
 
2595
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.
2596
2596
 
2597
- **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`.
2598
2598
 
2599
2599
  ### Staying current
2600
2600
 
@@ -2630,10 +2630,11 @@ Platform-specific configuration that doesn't belong in npm-standard fields. All
2630
2630
 
2631
2631
  | Field | Where used | Default | Purpose |
2632
2632
  |---|---|---|---|
2633
- | `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. |
2634
- | `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. |
2635
2634
  | `runtimePolicy` | `dist/runtime-pin.json` | unset | Declares how far past the recorded runtime version a host may move a site. |
2636
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
+
2637
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.
2638
2639
 
2639
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.
@@ -36,7 +36,12 @@ 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'
42
47
  import { publishScope } from '../utils/registry-orgs.js'
@@ -48,9 +53,12 @@ import { compareSemverPrecedence } from '../utils/semver-precedence.js'
48
53
  * already has it; nothing to do). Uses the SAME resolver the build uses
49
54
  * (`detectFoundationType`), so "which foundation" never drifts between them.
50
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
+ *
51
59
  * @param {string} siteDir
52
60
  * @param {object} siteYml - parsed site.yml
53
- * @returns {{ dir: string, scopedName: string|null, version: string|null }|null}
61
+ * @returns {{ dir: string, version: string|null }|null}
54
62
  */
55
63
  export function resolveLocalFoundation(siteDir, siteYml) {
56
64
  const decl = siteYml?.foundation
@@ -64,11 +72,7 @@ export function resolveLocalFoundation(siteDir, siteYml) {
64
72
  return null
65
73
  }
66
74
  if (!info || info.type !== 'local' || !info.path) return null
67
- return {
68
- dir: info.path,
69
- scopedName: foundationScopedName(info.path),
70
- version: readPkgField(info.path, 'version')
71
- }
75
+ return { dir: info.path, version: readPkgField(info.path, 'version') }
72
76
  }
73
77
 
74
78
  /**
@@ -82,7 +86,7 @@ export function resolveLocalFoundation(siteDir, siteYml) {
82
86
  *
83
87
  * @param {string} siteDir
84
88
  * @param {object} siteYml - parsed site.yml
85
- * @returns {Array<{ decl: string, dir: string, scopedName: string|null, version: string|null }>}
89
+ * @returns {Array<{ decl: string, dir: string, version: string|null }>}
86
90
  * `decl` is the authored declaration, which is the wire entry's `$id` — the key
87
91
  * publish stamps the pinned ref back onto.
88
92
  */
@@ -107,41 +111,43 @@ export function resolveLocalExtensions(siteDir, siteYml) {
107
111
  continue
108
112
  }
109
113
  if (!info || info.type !== 'local' || !info.path) continue
110
- out.push({
111
- decl,
112
- dir: info.path,
113
- scopedName: foundationScopedName(info.path),
114
- version: readPkgField(info.path, 'version')
115
- })
114
+ out.push({ decl, dir: info.path, version: readPkgField(info.path, 'version') })
116
115
  }
117
116
  return out
118
117
  }
119
118
 
120
- // The foundation's scoped catalog name (`@org/name`) from its package.json — an
121
- // already-scoped name, else `uniweb.scope` + a bare one. Null when neither
122
- // yields a scoped name (then we can't look up the registered version, so the
123
- // caller treats the foundation as "release it and let register pick the scope").
124
- //
125
- // ⛔ The name is `uniweb.id` when set, exactly as the build reads it for the
126
- // schema that `register` submits (`build/src/schema.js`). Reading `name` alone —
127
- // as this did until 2026-09-17 — looked a `uniweb.id` foundation up under a name
128
- // the catalog does not have, so every push re-released it, and pinned the site
129
- // to that same wrong name.
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
- 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
135
142
  try {
136
- const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
137
- const name = pkg?.uniweb?.id || pkg?.name
138
- if (typeof name === 'string' && name.startsWith('@')) return name
139
- const scope = publishScope(pkg?.uniweb?.scope)
140
- if (scope && name) return `${scope}/${name}`
141
- return null
143
+ ;({ name } = await readFoundationName(dir))
142
144
  } catch {
143
145
  return null
144
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
145
151
  }
146
152
 
147
153
  function readPkgField(dir, field) {
@@ -225,7 +231,7 @@ export async function bringFoundationAlong({
225
231
  * it in the messages.
226
232
  *
227
233
  * @param {object} o
228
- * @param {{dir: string, scopedName: string|null, version: string|null}} o.local
234
+ * @param {{dir: string, version: string|null}} o.local
229
235
  * @param {'foundation'|'extension'} o.kind
230
236
  * @returns {Promise<{ released: boolean, proceed: boolean, ref: string|null }>}
231
237
  */
@@ -241,9 +247,10 @@ async function bringLocalCodeAlong({
241
247
  verb = 'publish'
242
248
  }) {
243
249
  const Kind = kind === 'extension' ? 'Extension ' : 'Foundation'
250
+ const scopedName = await foundationScopedName(local.dir)
244
251
  const label =
245
- local.scopedName || local.version
246
- ? `${local.scopedName || kind}${local.version ? `@${local.version}` : ''}`
252
+ scopedName || local.version
253
+ ? `${scopedName || kind}${local.version ? `@${local.version}` : ''}`
247
254
  : `the local ${kind}`
248
255
  const skipPrompts =
249
256
  args.includes('--yes') ||
@@ -266,10 +273,11 @@ async function bringLocalCodeAlong({
266
273
  const noRelease = args.includes('--no-release')
267
274
 
268
275
  // The pinned ref to stamp on the pushed site — read at RETURN time (after any
269
- // release), so it reflects the released version + the scope register derived.
270
- // null when no scoped ref can be formed (then the site.yml ref is forwarded).
271
- const pinnedRef = () => {
272
- 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)
273
281
  const v = readPkgField(local.dir, 'version')
274
282
  return s && v ? `${s}@${v}` : null
275
283
  }
@@ -279,8 +287,8 @@ async function bringLocalCodeAlong({
279
287
  // so on a bumped-but-unreleased foundation it names a version nobody can serve. Any
280
288
  // branch that skips a release must bind to this instead.
281
289
  const registeredRef = (reg) =>
282
- local.scopedName && reg?.latest_version
283
- ? `${local.scopedName}@${reg.latest_version}`
290
+ scopedName && reg?.latest_version
291
+ ? `${scopedName}@${reg.latest_version}`
284
292
  : null
285
293
 
286
294
  // Dry-run reports the intent WITHOUT touching the network — it must not force
@@ -290,24 +298,23 @@ async function bringLocalCodeAlong({
290
298
  `${Kind} : ${label} — local; would release if changed or not yet registered`
291
299
  )
292
300
  // The ref still comes back where one can be formed: it is read from the
293
- // foundation's own package.json, so it costs no network, and an offline preview
294
- // that omitted it would emit a document the real run would not — the one thing
295
- // `-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.
296
304
  //
297
305
  // ⚠️ It is null for a foundation that has NEVER been registered and carries no
298
- // scope (a freshly scaffolded `name: "src"`), because the scope is what
299
- // `register` writes back (`writePkgScope`). So the preview shows the authored
300
- // value there, and the first real push — which releases, and so acquires the
301
- // scope — sends the pinned ref instead. That gap is unavoidable offline: before
302
- // the first release there is no registered name to name.
303
- 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() }
304
313
  }
305
314
 
306
315
  // Ask the catalog what it has. Null → not registered (or the backend can't
307
316
  // answer / no scoped name to look up) → release.
308
- const reg = local.scopedName
309
- ? await client.readFoundationLatest(local.scopedName)
310
- : null
317
+ const reg = scopedName ? await client.readFoundationLatest(scopedName) : null
311
318
 
312
319
  if (!reg) {
313
320
  // ⛔ Nothing to bind to. Releasing anyway would be the opposite of what was asked,
@@ -326,7 +333,7 @@ async function bringLocalCodeAlong({
326
333
  return {
327
334
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
328
335
  proceed: true,
329
- ref: pinnedRef()
336
+ ref: await pinnedRef()
330
337
  }
331
338
  }
332
339
 
@@ -339,7 +346,7 @@ async function bringLocalCodeAlong({
339
346
  say.dim(
340
347
  `${Kind} : ${label} — unchanged since release (digest matches); nothing to release.`
341
348
  )
342
- return { released: false, proceed: true, ref: pinnedRef() }
349
+ return { released: false, proceed: true, ref: await pinnedRef() }
343
350
  }
344
351
 
345
352
  // A different version locally → a new version to release.
@@ -369,7 +376,7 @@ async function bringLocalCodeAlong({
369
376
  return {
370
377
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
371
378
  proceed: true,
372
- ref: pinnedRef()
379
+ ref: await pinnedRef()
373
380
  }
374
381
  }
375
382
 
@@ -390,7 +397,7 @@ async function bringLocalCodeAlong({
390
397
  say.dim(
391
398
  'Proceeding without re-releasing — pass nothing to re-deliver, or bump the version to release a change.'
392
399
  )
393
- return { released: false, proceed: true, ref: pinnedRef() }
400
+ return { released: false, proceed: true, ref: await pinnedRef() }
394
401
  }
395
402
  const reRelease = await confirm(
396
403
  `Re-release ${label} to be sure its code is current?`,
@@ -400,9 +407,9 @@ async function bringLocalCodeAlong({
400
407
  return {
401
408
  released: releaseFoundation(local, args, cliBin, say, client?.origin),
402
409
  proceed: true,
403
- ref: pinnedRef()
410
+ ref: await pinnedRef()
404
411
  }
405
- return { released: false, proceed: true, ref: pinnedRef() }
412
+ return { released: false, proceed: true, ref: await pinnedRef() }
406
413
  }
407
414
 
408
415
  // Case 3 (§4): the code was edited but the version wasn't bumped. The
@@ -438,7 +445,7 @@ async function bringLocalCodeAlong({
438
445
  `Local ${label} differs from the registered ${reg.latest_version} and the version wasn't bumped — ` +
439
446
  `shipping against the registered code. Your local changes will NOT be live.`
440
447
  )
441
- return { released: false, proceed: true, ref: pinnedRef() }
448
+ return { released: false, proceed: true, ref: await pinnedRef() }
442
449
  }
443
450
 
444
451
  // 2. Nobody to ask. Refuse, and name both real options as runnable commands so
@@ -476,7 +483,7 @@ async function bringLocalCodeAlong({
476
483
  )
477
484
  return { released: false, proceed: false, ref: null }
478
485
  }
479
- return { released: false, proceed: true, ref: pinnedRef() }
486
+ return { released: false, proceed: true, ref: await pinnedRef() }
480
487
  }
481
488
 
482
489
  // Build the foundation so its dist/ can be fingerprinted. Idempotent — the
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { writeFileSync, readFileSync, mkdirSync, existsSync, unlinkSync } from 'node:fs'
15
- import { join, dirname } from 'node:path'
15
+ import { join, dirname, relative, isAbsolute } from 'node:path'
16
16
  import yaml from 'js-yaml'
17
17
  import { hasUncommittedContent } from '../utils/git.js'
18
18
  import { humanBytes } from '../utils/bytes.js'
@@ -1270,6 +1270,8 @@ export async function pushSyncPackages({
1270
1270
 
1271
1271
  const wrote = []
1272
1272
  let finalizedTotal = 0
1273
+ // Records sent as drafts that the backend stored enabled — reported at the end.
1274
+ let draftsNotKept = []
1273
1275
 
1274
1276
  // POST one lane via the client and parse the JSON response. `doRequest` is a thunk
1275
1277
  // returning the client's Response promise (so the "Pushing …" line prints before the
@@ -1658,6 +1660,7 @@ export async function pushSyncPackages({
1658
1660
  }
1659
1661
  for (const w of bf.warnings) note(`! ${w}`)
1660
1662
  for (const d of bf.deferred) note(`↷ ${d.id ?? `#${d.index}`}: ${d.reason}`)
1663
+ draftsNotKept = bf.notKeptAsDrafts || []
1661
1664
  if (bf.updated.length)
1662
1665
  wrote.push(`wrote ${bf.updated.length} record file(s)`)
1663
1666
  // ⭐ BANK THE FOLDER'S PLACEMENT IDENTITY. The records carry their own `$uuid`
@@ -1723,5 +1726,21 @@ export async function pushSyncPackages({
1723
1726
  )
1724
1727
  }
1725
1728
  }
1729
+ // ⛔ A DRAFT THE BACKEND STORED ENABLED FAILS THE PUSH, and only here, after every
1730
+ // piece of sync state above is banked: the push itself happened, and the next one must
1731
+ // see it. What must not happen is a publish that goes live on top of it, which is why
1732
+ // the exit code carries it (publish stops on anything but 0). The files keep their
1733
+ // `draft: true` (see backfillEntityUuids).
1734
+ if (draftsNotKept.length) {
1735
+ const one = draftsNotKept.length === 1
1736
+ const shown = draftsNotKept.map((p) => (isAbsolute(p) ? relative(siteDir, p) : p))
1737
+ error(
1738
+ `This backend did not keep ${one ? 'a record' : `${shown.length} records`} as a draft, ` +
1739
+ `so ${one ? 'it' : 'they'} would be delivered once the site is published:\n ` +
1740
+ shown.join('\n ') +
1741
+ '\n The backend does not support `draft: true` yet. Once it does, push again with --all.'
1742
+ )
1743
+ return { exitCode: 1, boundSiteUuid, finalizedTotal, wrote }
1744
+ }
1726
1745
  return { exitCode: 0, boundSiteUuid, finalizedTotal, wrote }
1727
1746
  }
@@ -16,7 +16,7 @@ import { readFile, writeFile, mkdir } from 'node:fs/promises'
16
16
  import { join, relative, basename } from 'node:path'
17
17
  import prompts from 'prompts'
18
18
  import yaml from 'js-yaml'
19
- import { resolveFoundationSrcPath } from '@uniweb/build'
19
+ import { resolveFoundationSrcPath, readFoundationName } from '@uniweb/build'
20
20
  import {
21
21
  scaffoldFoundation,
22
22
  scaffoldSite,
@@ -42,6 +42,10 @@ import {
42
42
  resolveUniqueName
43
43
  } from '../utils/names.js'
44
44
  import { findWorkspaceRoot } from '../utils/workspace.js'
45
+ import {
46
+ normalizeFoundationName,
47
+ ensureFoundationName
48
+ } from '../utils/foundation-name.js'
45
49
  import {
46
50
  detectPackageManager,
47
51
  detectWorkspacePm,
@@ -366,6 +370,12 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
366
370
  let { packageName } = placement
367
371
  const fullPath = join(rootDir, relativePath)
368
372
 
373
+ // The foundation's own name — what it registers as, written to main.js — is
374
+ // the name asked for, before any workspace suffix below; with none (`src`),
375
+ // the project's.
376
+ const askedName =
377
+ normalizeFoundationName(packageName) || normalizeFoundationName(projectName)
378
+
369
379
  // Validate the derived package name (format + reserved-name check). The
370
380
  // auto-derived `src` default is grandfathered in (`src` IS reserved
371
381
  // but `src` is the convention for "the package that lives in src/").
@@ -435,10 +445,15 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
435
445
  }
436
446
 
437
447
  // Scaffold
448
+ const registryName = await unclaimedFoundationName(rootDir, [
449
+ askedName,
450
+ normalizeFoundationName(packageName)
451
+ ])
438
452
  await scaffoldFoundation(
439
453
  fullPath,
440
454
  {
441
455
  name: packageName,
456
+ registryName,
442
457
  projectName,
443
458
  isExtension: false
444
459
  },
@@ -447,9 +462,11 @@ async function addFoundation(rootDir, projectName, opts, pm = 'pnpm') {
447
462
  }
448
463
  )
449
464
 
450
- // Apply template content if --from specified
465
+ // Apply template content if --from specified. Its main.js replaces the
466
+ // scaffolded one, so the name is given again unless the template names it.
451
467
  if (opts.from) {
452
468
  await applyFromTemplate(opts.from, 'foundation', fullPath, projectName)
469
+ ensureFoundationName(fullPath, registryName)
453
470
  }
454
471
 
455
472
  // Register the package in pnpm-workspace.yaml — by exact path, not by glob.
@@ -685,11 +702,18 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
685
702
  process.exit(1)
686
703
  }
687
704
 
688
- // Scaffold foundation with extension flag
705
+ // Scaffold foundation with extension flag. Its name — what it registers as —
706
+ // is the one asked for, not the package's suffixed one, unless a foundation in
707
+ // the workspace already registers as that.
708
+ const registryName = await unclaimedFoundationName(rootDir, [
709
+ normalizeFoundationName(name),
710
+ normalizeFoundationName(extensionPackageName)
711
+ ])
689
712
  await scaffoldFoundation(
690
713
  fullPath,
691
714
  {
692
715
  name: extensionPackageName,
716
+ registryName,
693
717
  projectName,
694
718
  isExtension: true
695
719
  },
@@ -701,6 +725,7 @@ async function addExtension(rootDir, projectName, opts, pm = 'pnpm') {
701
725
  // Apply template content if --from specified
702
726
  if (opts.from) {
703
727
  await applyFromTemplate(opts.from, 'extension', fullPath, projectName)
728
+ ensureFoundationName(fullPath, registryName)
704
729
  }
705
730
 
706
731
  // Update workspace globs
@@ -848,12 +873,18 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
848
873
 
849
874
  const progressCb = (msg) => info(` ${msg}`)
850
875
 
851
- // Scaffold foundation (folder: src/, package name: <project>-src)
876
+ // Scaffold foundation (folder: src/, package name: <project>-src, name — what
877
+ // it registers as — <project>)
852
878
  info(`Creating foundation: ${foundationPkgName}...`)
879
+ const registryName = await unclaimedFoundationName(rootDir, [
880
+ normalizeFoundationName(name),
881
+ normalizeFoundationName(foundationPkgName)
882
+ ])
853
883
  await scaffoldFoundation(
854
884
  join(projectDir, 'src'),
855
885
  {
856
886
  name: foundationPkgName,
887
+ registryName,
857
888
  projectName,
858
889
  isExtension: false
859
890
  },
@@ -882,6 +913,7 @@ async function addProject(rootDir, projectName, opts, pm = 'pnpm') {
882
913
  join(projectDir, 'src'),
883
914
  projectName
884
915
  )
916
+ ensureFoundationName(join(projectDir, 'src'), registryName)
885
917
  await applyFromTemplate(
886
918
  opts.from,
887
919
  'site',
@@ -987,6 +1019,32 @@ function computeFoundationPath(sitePath, foundationPath) {
987
1019
  return `file:${rel}`
988
1020
  }
989
1021
 
1022
+ /**
1023
+ * The first of `candidates` that no foundation or extension in the workspace
1024
+ * registers as — null when each is taken or missing, and `uniweb register` asks.
1025
+ *
1026
+ * A package name is kept unique by suffixing (`ui-ext`), but what a package
1027
+ * REGISTERS as is its main.js name: two foundations named `ui` in one workspace
1028
+ * would both register `@org/ui`, and the second would land on the first's
1029
+ * version. Compared without scope, so `@acme/ui` counts as `ui`.
1030
+ *
1031
+ * @param {string} rootDir
1032
+ * @param {Array<string|null>} candidates
1033
+ * @returns {Promise<string|null>}
1034
+ */
1035
+ async function unclaimedFoundationName(rootDir, candidates) {
1036
+ const taken = new Set()
1037
+ for (const f of await discoverFoundations(rootDir)) {
1038
+ try {
1039
+ const { name } = await readFoundationName(join(rootDir, f.path))
1040
+ if (name) taken.add(name.replace(/^@[^/]+\//, ''))
1041
+ } catch {
1042
+ // An unreadable main.js — the build reports it; it claims no name here.
1043
+ }
1044
+ }
1045
+ return candidates.find((c) => c && !taken.has(c)) || null
1046
+ }
1047
+
990
1048
  /**
991
1049
  * Apply content from a template to a scaffolded package
992
1050
  *
@@ -399,7 +399,7 @@ export async function clone(args = [], deps = {}) {
399
399
  // bytes on disk, not of what the scaffolder first wrote.
400
400
  recordWritten(
401
401
  siteDir,
402
- ['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml']
402
+ ['site.yml', 'theme.yml', 'head.html', 'queries.yml', join('records', 'folder.yml')]
403
403
  .map((f) => join(siteDir, f))
404
404
  .filter((p) => existsSync(p))
405
405
  )
@@ -65,7 +65,7 @@ import {
65
65
  import { tmpdir } from 'node:os'
66
66
  import { createHash } from 'node:crypto'
67
67
  import { createInterface } from 'node:readline/promises'
68
- import { join, dirname, relative } from 'node:path'
68
+ import { join, dirname, relative, resolve } from 'node:path'
69
69
  import yaml from 'js-yaml'
70
70
  import { downloadMissingAssets } from '../backend/asset-download.js'
71
71
  import {
@@ -856,9 +856,16 @@ export async function pull(args = [], deps = {}) {
856
856
  resolveDeclaration: (name) => declByModel.get(name) || null
857
857
  }
858
858
  })
859
- if (report.records === 'updated') info('Wrote records.yml')
860
- // The backend's folder has no sub-folders, and records.yml holds nothing else.
861
- else if (report.records === 'removed') info('Removed records.yml — the folder has no sub-folders')
859
+ // The folder's organization, `folder.yml` in the records directory — named as
860
+ // the author would write it (`records/folder.yml`, or under `paths.records`).
861
+ if (report.records === 'updated') {
862
+ info(`Wrote ${report.recordsFile}`)
863
+ wrote.push(resolve(siteDir, report.recordsFile))
864
+ } else if (report.records === 'removed') {
865
+ // The backend's folder has no sub-folders, and the file holds nothing else.
866
+ info(`Removed ${report.recordsFile} — the folder has no sub-folders`)
867
+ removed.push(resolve(siteDir, report.recordsFile))
868
+ }
862
869
  records += report.placed.length + report.updated.length
863
870
  for (const s of report.skipped)
864
871
  note(`↷ ${s.slug ?? s.uuid ?? '(record)'}: ${s.reason}`)
@@ -892,7 +899,8 @@ export async function pull(args = [], deps = {}) {
892
899
  siteDir,
893
900
  [
894
901
  ...wrote,
895
- ...['site.yml', 'theme.yml', 'head.html', 'queries.yml', 'records.yml'].map((f) =>
902
+ // The records folder's `folder.yml` is in `wrote` when this pull wrote it.
903
+ ...['site.yml', 'theme.yml', 'head.html', 'queries.yml'].map((f) =>
896
904
  join(siteDir, f)
897
905
  )
898
906
  ],
@@ -3,15 +3,14 @@
3
3
  * standalone schemas package (the data schemas alone, no foundation), to the
4
4
  * registry as one names-only `.uwx` document (uwx-format.md §5).
5
5
  *
6
- * `uniweb login && uniweb register`. Distinct from `uniweb publish` (which
7
- * targets the legacy platform) — `register` talks to the registry over HTTP at a
8
- * configurable endpoint.
6
+ * `uniweb login && uniweb register`. Distinct from `uniweb publish`, which ships a
7
+ * SITE — and releases the site's local foundation by running this command
8
+ * (`backend/foundation-bring-along.js`).
9
9
  *
10
- * If the foundation's `dist/` is missing or version-stale (the baked schema
11
- * version differs from package.json), `register` builds it first — the same
12
- * build-if-stale `uniweb publish` does, so `register` is a full drop-in.
13
- * Preview paths (`--dry-run`, `-o`) never write to `dist/`; they require a
14
- * pre-built foundation.
10
+ * If the foundation's `dist/` is missing or stale — no build, a version bump
11
+ * since it, a source file newer than it, no SSR bundle (`foundationNeedsBuild`)
12
+ * — `register` builds it first. Preview paths (`--dry-run`, `-o`) never write to
13
+ * `dist/`; they require a pre-built foundation.
15
14
  *
16
15
  * Run from a foundation, or from a schemas-only package — a package that exports
17
16
  * schemas (e.g. `@uniweb/schemas`, any `@org/schemas`) or a bare `schemas/*.yml`
@@ -64,13 +63,20 @@ import {
64
63
  statSync
65
64
  } from 'node:fs'
66
65
  import { execSync } from 'node:child_process'
67
- import { resolve, join } from 'node:path'
66
+ import { resolve, join, relative } from 'node:path'
67
+ import prompts from 'prompts'
68
68
  import { buildRegistryPackage, buildSchemaOnlyPackage } from '@uniweb/build/uwx'
69
69
  import {
70
70
  classifyPackage,
71
71
  isSchemasPackage,
72
- collectStandaloneSchemas
72
+ collectStandaloneSchemas,
73
+ readFoundationName,
74
+ checkFoundationName
73
75
  } from '@uniweb/build'
76
+ import {
77
+ suggestFoundationName,
78
+ writeFoundationName
79
+ } from '../utils/foundation-name.js'
74
80
  import { readRegistryAuth } from '../utils/registry-auth.js'
75
81
  import {
76
82
  collectDistFiles,
@@ -209,6 +215,84 @@ async function resolveFoundationDir(args) {
209
215
  process.exit(1)
210
216
  }
211
217
 
218
+ /**
219
+ * The foundation's name, settled before anything is built or sent.
220
+ *
221
+ * A foundation registers as `@org/<name>`, and sites pin it by that name. The name
222
+ * is `main.js`'s `name`, else package.json's — `readFoundationName`, the rule the
223
+ * build reads for the schema this command submits. ⛔ `src` and `foundation` are
224
+ * the folder the code sits in, not the code: a project scaffolded before the name
225
+ * moved to main.js has package name `src`, and every such project in an org would
226
+ * register the same `@org/src`. So a name that cannot register is ASKED FOR here,
227
+ * once, and kept in main.js — or, with nobody to ask, refused with the line to add.
228
+ * Before the build, so a written name is built into the schema like any edit.
229
+ *
230
+ * @param {string} targetDir
231
+ * @param {{ args: string[], isPreview: boolean }} o
232
+ * @returns {Promise<boolean>} false → stop; the reason is printed
233
+ */
234
+ export async function settleFoundationName(targetDir, { args, isPreview }) {
235
+ let read
236
+ try {
237
+ read = await readFoundationName(targetDir)
238
+ } catch (err) {
239
+ error(err.message)
240
+ return false
241
+ }
242
+ const problem = checkFoundationName(read.name)
243
+ if (!problem) return true
244
+
245
+ const where = relative(process.cwd(), read.mainFile) || read.mainFile
246
+ const suggestion = suggestFoundationName(targetDir)
247
+ const reason = `${problem[0].toUpperCase()}${problem.slice(1)}.`
248
+
249
+ // Nobody to ask — a script, a preview (which writes nothing), or --json (whose
250
+ // stdout is a data channel a prompt would corrupt). Name the fix exactly.
251
+ if (isPreview || jsonMode || isNonInteractive(args)) {
252
+ error(`This foundation has no name it can register under. ${reason}`)
253
+ log(` Name it in ${colors.bright}${where}${colors.reset}, in the default export:`)
254
+ log(` ${colors.bright}name: '${suggestion || '<name>'}',${colors.reset}`)
255
+ log(` ${colors.dim}It registers as @<org>/<name>, and sites pin that name.${colors.reset}`)
256
+ return false
257
+ }
258
+
259
+ info(`This foundation needs a name to register under. ${reason}`)
260
+ const { name } = await prompts(
261
+ {
262
+ type: 'text',
263
+ name: 'name',
264
+ message: 'Foundation name (it registers as @<org>/<name>):',
265
+ initial: suggestion || '',
266
+ validate: (v) => checkFoundationName(String(v).trim()) || true
267
+ },
268
+ {
269
+ onCancel: () => {
270
+ log('\nRegister cancelled.')
271
+ process.exit(0)
272
+ }
273
+ }
274
+ )
275
+ const chosen = String(name || '').trim()
276
+ if (!chosen) return false
277
+
278
+ const written = writeFoundationName(read.mainFile, chosen, {
279
+ replace: read.source === 'main.js' ? read.name : null
280
+ })
281
+ if (!written.ok) {
282
+ error(`Could not write the name: ${written.reason}.`)
283
+ log(` Add it to the default export in ${colors.bright}${where}${colors.reset}: name: '${chosen}',`)
284
+ return false
285
+ }
286
+ // Read it back through the build's own rule — the text edit is only a means.
287
+ const again = await readFoundationName(targetDir).catch(() => null)
288
+ if (again?.name !== chosen) {
289
+ error(`${where} now names the foundation '${chosen}', but it still reads as "${again?.name}".`)
290
+ return false
291
+ }
292
+ success(`Named the foundation ${colors.bright}${chosen}${colors.reset} — kept in ${where}.`)
293
+ return true
294
+ }
295
+
212
296
  // Directories that are never foundation source. `dist` is the output we compare
213
297
  // against; the rest are tooling. Dotted entries are skipped wholesale.
214
298
  const NON_SOURCE_DIRS = new Set(['dist', 'node_modules'])
@@ -443,8 +527,11 @@ async function runRegister(args = []) {
443
527
  return { exitCode: 2 }
444
528
  }
445
529
  } else {
446
- // Build-if-stale (mirrors `uniweb publish`): a missing or version-stale
447
- // dist/ gets (re)built before we read its schema. Preview paths
530
+ if (!(await settleFoundationName(targetDir, { args, isPreview }))) {
531
+ return { exitCode: 2 }
532
+ }
533
+ // Build-if-stale (`foundationNeedsBuild`): a missing or stale dist/ gets
534
+ // (re)built before we read its schema. Preview paths
448
535
  // (--dry-run / -o) must not write to dist/, so they require a pre-built
449
536
  // foundation and say so instead of building.
450
537
  const { needs, reason } = foundationNeedsBuild(targetDir)
@@ -20,7 +20,7 @@
20
20
  *
21
21
  * Extensions are technically a flavor of foundation (same build, same
22
22
  * package.json shape, distinguished only by `extension: true` in
23
- * src/foundation.js or `role: 'extension'` in the built schema). The
23
+ * main.js or `role: 'extension'` in the built schema). The
24
24
  * rename verb still keeps them on a separate subcommand because the
25
25
  * touch-point sets differ — `rename foundation` against an extension
26
26
  * would update the wrong things. Each subcommand guards its target
@@ -30,11 +30,13 @@
30
30
  * (target name already taken, target not found, folder collision,
31
31
  * type mismatch) we bail with a clear message and no partial state.
32
32
  *
33
- * Out of scope: registry side. The registered id (the scoped
34
- * `package.json::name` / `uniweb.id`) is independent of the workspace name and
35
- * stays untouched. There is no registry-rename flag — a registered version is
36
- * immutable; to change a foundation's registered identity, register it under
37
- * the new name (consuming sites repoint their `foundation:` ref).
33
+ * Out of scope: registry side. What a foundation registers as is `name` in its
34
+ * main.js, which is independent of the workspace name and stays untouched —
35
+ * except for a foundation with no main.js `name`, whose package name IS its name
36
+ * (`readFoundationName`), so renaming the package renames what it would register
37
+ * as. There is no registry-rename flag — a registered version is immutable; to
38
+ * change a foundation's registered name, change main.js's `name` and register
39
+ * again (consuming sites repoint their `foundation:` ref).
38
40
  *
39
41
  * Usage:
40
42
  * uniweb rename foundation <old> <new>
@@ -632,9 +634,10 @@ ${colors.bright}What rename extension does:${colors.reset}
632
634
  • Updates pnpm-workspace.yaml + package.json::workspaces.
633
635
 
634
636
  ${colors.bright}What rename does NOT do (any subcommand):${colors.reset}
635
- • Push to the registry. The registered id (package.json::uniweb.id) is
636
- independent. To register under a new name, set it and run \`${prefix} register\`
637
- (alias \`${prefix} release\`) — there is no rename flag.
637
+ • Push to the registry, or change what a foundation registers as — that is
638
+ \`name\` in its main.js, independent of the package name. To register under
639
+ a new name, change it there and run \`${prefix} register\` (alias
640
+ \`${prefix} release\`) — there is no rename flag.
638
641
 
639
642
  ${colors.bright}Examples:${colors.reset}
640
643
  ${prefix} rename foundation src marketing-src
@@ -28,7 +28,10 @@ import { resolveSiteDir } from './deploy.js'
28
28
  import { probeUnpushed } from '../backend/site-sync.js'
29
29
  import { BackendClient, resolveBackendOrigin } from '../backend/client.js'
30
30
  import { readBackendState } from '@uniweb/build/uwx'
31
- import { resolveLocalFoundation } from '../backend/foundation-bring-along.js'
31
+ import {
32
+ resolveLocalFoundation,
33
+ foundationScopedName
34
+ } from '../backend/foundation-bring-along.js'
32
35
  import { computeFoundationDigest } from '../utils/code-upload.js'
33
36
  import { checkFlags } from '../utils/flag-guard.js'
34
37
 
@@ -124,7 +127,8 @@ export async function status(args = []) {
124
127
  // site.yml ref. The digest compare is read-only — it never builds, so it
125
128
  // only fires when the local foundation is already built (dist present).
126
129
  const local = resolveLocalFoundation(siteDir, siteYml)
127
- const lookupName = local?.scopedName || fndScope
130
+ const lookupName =
131
+ (local && (await foundationScopedName(local.dir))) || fndScope
128
132
  if (lookupName) fdnLatest = await client.readFoundationLatest(lookupName)
129
133
  if (local?.dir && fdnLatest?.digest) {
130
134
  const localDigest = computeFoundationDigest(join(local.dir, 'dist'))
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-21T21:47:05.925Z",
3
+ "generatedAt": "2026-09-21T23:16:47.962Z",
4
4
  "packages": {
5
5
  "@uniweb/api": {
6
6
  "version": "0.4.1",
@@ -10,7 +10,7 @@
10
10
  ]
11
11
  },
12
12
  "@uniweb/build": {
13
- "version": "0.57.0",
13
+ "version": "0.58.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.29.7",
38
+ "version": "0.29.8",
39
39
  "path": "framework/core",
40
40
  "deps": [
41
41
  "@uniweb/semantic-parser",
@@ -83,7 +83,7 @@
83
83
  ]
84
84
  },
85
85
  "@uniweb/runtime": {
86
- "version": "0.27.3",
86
+ "version": "0.27.4",
87
87
  "path": "framework/runtime",
88
88
  "deps": [
89
89
  "@uniweb/core",
@@ -116,7 +116,7 @@
116
116
  "deps": []
117
117
  },
118
118
  "@uniweb/templates": {
119
- "version": "0.15.0",
119
+ "version": "0.16.0",
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.0",
129
+ "version": "0.10.1",
130
130
  "path": "framework/unipress",
131
131
  "deps": [
132
132
  "@uniweb/build",
package/src/index.js CHANGED
@@ -53,6 +53,10 @@ import {
53
53
  mergeTemplateDependencies,
54
54
  getWorkspaceTemplateOutputs
55
55
  } from './utils/scaffold.js'
56
+ import {
57
+ normalizeFoundationName,
58
+ ensureFoundationName
59
+ } from './utils/foundation-name.js'
56
60
  import {
57
61
  detectPackageManager,
58
62
  detectWorkspacePm,
@@ -354,12 +358,16 @@ async function createFromPackageTemplates(
354
358
  // 2. Scaffold foundation (folder: src/, package name: src)
355
359
  // The folder name 'src' carries the meaning — a foundation is the site's
356
360
  // source code. The package name 'src' keeps it unique within the
357
- // workspace, since 'site' is taken by the site package.
361
+ // workspace, since 'site' is taken by the site package. Neither is the
362
+ // foundation's NAME — what it registers as — which is main.js's `name`:
363
+ // the project's, since a project's code is its foundation.
358
364
  onProgress?.('Creating foundation...')
365
+ const registryName = normalizeFoundationName(projectName)
359
366
  await scaffoldFoundation(
360
367
  join(projectDir, 'src'),
361
368
  {
362
369
  name: 'src',
370
+ registryName,
363
371
  projectName,
364
372
  isExtension: false
365
373
  },
@@ -380,10 +388,12 @@ async function createFromPackageTemplates(
380
388
  { onProgress, onWarning }
381
389
  )
382
390
 
383
- // 4. Apply starter content (unless creating a "none" project)
391
+ // 4. Apply starter content (unless creating a "none" project). Its main.js
392
+ // replaces the scaffolded one, so the name is given again.
384
393
  if (includeStarter) {
385
394
  onProgress?.('Adding starter content...')
386
395
  await applyStarter(projectDir, { projectName }, { onProgress, onWarning })
396
+ ensureFoundationName(join(projectDir, 'src'), registryName)
387
397
  }
388
398
 
389
399
  success(`Created project: ${projectName}`)
@@ -475,15 +485,31 @@ async function createFromContentTemplate(
475
485
  )
476
486
 
477
487
  // 2. Scaffold and apply content for each package
488
+ const claimed = new Set() // names this project's foundations register as
478
489
  for (const pkg of placed) {
479
490
  const fullPath = join(projectDir, pkg.relativePath)
480
491
 
492
+ // A foundation's name — what it registers as — is the template's name for
493
+ // the package when that is one, else the project's (`src` and `foundation`
494
+ // name a folder); `<project>-<package>` if another package here took it. A
495
+ // template's own main.js `name` outranks all of them.
496
+ let registryName = null
497
+ if (pkg.type === 'foundation' || pkg.type === 'extension') {
498
+ registryName =
499
+ [
500
+ normalizeFoundationName(pkg.name) || normalizeFoundationName(projectName),
501
+ normalizeFoundationName(`${projectName}-${pkg.name}`)
502
+ ].find((n) => n && !claimed.has(n)) || null
503
+ if (registryName) claimed.add(registryName)
504
+ }
505
+
481
506
  if (pkg.type === 'foundation' || pkg.type === 'extension') {
482
507
  onProgress?.(`Creating ${pkg.type}: ${pkg.name}...`)
483
508
  await scaffoldFoundation(
484
509
  fullPath,
485
510
  {
486
511
  name: pkg.name,
512
+ registryName,
487
513
  projectName,
488
514
  isExtension: pkg.type === 'extension'
489
515
  },
@@ -534,6 +560,7 @@ async function createFromContentTemplate(
534
560
  renames: contentDir.renames
535
561
  }
536
562
  )
563
+ ensureFoundationName(fullPath, registryName)
537
564
  }
538
565
 
539
566
  // Merge template dependencies into package.json
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Giving a foundation its name — `name` in `main.js`'s default export.
3
+ *
4
+ * The rule itself (main.js `name`, else package.json `name`; `src` and `foundation`
5
+ * refused) is `@uniweb/build`'s `foundation-name.js`, shared by the build, `register`
6
+ * and `push`. This module only WRITES the name: the scaffold at `create` / `add`, and
7
+ * `register` when it asks for one.
8
+ *
9
+ * ⛔ NO `@uniweb/build` IMPORT, AND NO IMPORT OF main.js. `uniweb create` runs before
10
+ * any project exists: `@uniweb/build` is an optional peer that may not be installed,
11
+ * and main.js's own imports may not be either. Hence text scans rather than a load,
12
+ * and the one duplicated fact below, pinned to the build's list by
13
+ * `test/foundation-name.test.js`. What a foundation IS named is always read by the
14
+ * build (`readFoundationName`); these only decide where to write.
15
+ */
16
+
17
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
18
+ import { join, resolve, basename, dirname } from 'node:path'
19
+
20
+ // The folders a foundation's code lives in — never a foundation's name
21
+ // (`@uniweb/build`'s FORBIDDEN_FOUNDATION_NAMES; the test keeps them equal).
22
+ export const SCAFFOLD_FOLDER_NAMES = new Set(['src', 'foundation'])
23
+
24
+ // Written above the name wherever the CLI adds one — the scaffold's main.js
25
+ // template says the same.
26
+ const NAME_COMMENT = '// What this foundation registers as — @<org>/<name> — and what sites pin.'
27
+
28
+ /**
29
+ * A name made from free text — a project's name, a folder's — in the form a
30
+ * foundation name takes: lowercase letters, digits and inner hyphens.
31
+ * `My Site` → `my-site`. Null when nothing usable is left, or when what is left
32
+ * names a folder rather than a foundation.
33
+ *
34
+ * @param {unknown} raw
35
+ * @returns {string|null}
36
+ */
37
+ export function normalizeFoundationName(raw) {
38
+ const name = String(raw ?? '')
39
+ .trim()
40
+ .toLowerCase()
41
+ .replace(/^@[^/]*\//, '')
42
+ .replace(/[^a-z0-9]+/g, '-')
43
+ .replace(/^-+|-+$/g, '')
44
+ return name && !SCAFFOLD_FOLDER_NAMES.has(name) ? name : null
45
+ }
46
+
47
+ /**
48
+ * A name to offer a foundation that has none of its own: its folder's — or, for
49
+ * the `src/` and `foundation/` a scaffold makes, its project's folder. Null when
50
+ * neither makes one.
51
+ *
52
+ * @param {string} foundationDir
53
+ * @returns {string|null}
54
+ */
55
+ export function suggestFoundationName(foundationDir) {
56
+ const dir = resolve(foundationDir)
57
+ return (
58
+ normalizeFoundationName(basename(dir)) ||
59
+ normalizeFoundationName(basename(dirname(dir)))
60
+ )
61
+ }
62
+
63
+ /**
64
+ * Whether `main.js`'s default export names the foundation — a `name` key at the
65
+ * export's own level, not one nested in `props` or `vars`.
66
+ *
67
+ * A scan, not a parse (see the module note): it skips strings and comments and
68
+ * counts brackets, which is all an object literal of declarations needs. A name
69
+ * that arrives by a spread is invisible to it — the build's read is the truth.
70
+ *
71
+ * @param {string} text - main.js source
72
+ * @returns {boolean}
73
+ */
74
+ export function mainNamesFoundation(text) {
75
+ const opening = /export\s+default\s*\{/.exec(text)
76
+ if (!opening) return false
77
+ let depth = 1
78
+ let atKey = true // just after the opening brace, or a comma at the top level
79
+ for (let i = opening.index + opening[0].length; i < text.length && depth > 0; i++) {
80
+ const c = text[i]
81
+ if (c === '/' && text[i + 1] === '/') {
82
+ i = text.indexOf('\n', i)
83
+ if (i < 0) break
84
+ } else if (c === '/' && text[i + 1] === '*') {
85
+ i = text.indexOf('*/', i + 2) + 1
86
+ if (i <= 0) break
87
+ } else if (c === "'" || c === '"' || c === '`') {
88
+ let j = i + 1
89
+ while (j < text.length && text[j] !== c) j += text[j] === '\\' ? 2 : 1
90
+ if (depth === 1 && atKey && text.slice(i + 1, j) === 'name' && /^\s*:/.test(text.slice(j + 1))) {
91
+ return true
92
+ }
93
+ i = j
94
+ atKey = false
95
+ } else if ('{[('.includes(c)) {
96
+ depth++
97
+ atKey = false
98
+ } else if ('}])'.includes(c)) {
99
+ depth--
100
+ } else if (depth === 1 && c === ',') {
101
+ atKey = true
102
+ } else if (depth === 1 && atKey && /[A-Za-z_$]/.test(c)) {
103
+ const word = /^[A-Za-z_$][\w$]*/.exec(text.slice(i))[0]
104
+ // `name: 'x'`, or the shorthand `{ name }`
105
+ if (word === 'name' && /^\s*[:,}]/.test(text.slice(i + word.length))) return true
106
+ i += word.length - 1
107
+ atKey = false
108
+ } else if (!/\s/.test(c)) {
109
+ atKey = false
110
+ }
111
+ }
112
+ return false
113
+ }
114
+
115
+ /**
116
+ * Write `name` into a foundation's `main.js`.
117
+ *
118
+ * With `replace`, the one `name: '<replace>'` literal is rewritten — the case where
119
+ * main.js already names the foundation, with a name that cannot register. Without
120
+ * it, `name` becomes the default export's first key. A missing main.js is created.
121
+ *
122
+ * ⚠️ A text edit, not a parse: it handles `export default { … }` — every scaffold
123
+ * and every template — and says so otherwise rather than guessing.
124
+ *
125
+ * @param {string} mainFile
126
+ * @param {string} name - already a valid foundation name
127
+ * @param {{ replace?: string|null }} [opts]
128
+ * @returns {{ ok: true } | { ok: false, reason: string }}
129
+ */
130
+ export function writeFoundationName(mainFile, name, { replace = null } = {}) {
131
+ const line = `${NAME_COMMENT}\n name: '${name}',`
132
+ if (!existsSync(mainFile)) {
133
+ writeFileSync(mainFile, `export default {\n ${line}\n}\n`)
134
+ return { ok: true }
135
+ }
136
+ const text = readFileSync(mainFile, 'utf8')
137
+
138
+ if (replace !== null) {
139
+ const literal = new RegExp(
140
+ `(\\bname\\s*:\\s*)(['"\`])${replace.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\2`,
141
+ 'g'
142
+ )
143
+ const hits = text.match(literal) || []
144
+ if (hits.length !== 1) {
145
+ return {
146
+ ok: false,
147
+ reason: `main.js does not name the foundation "${replace}" in exactly one place`
148
+ }
149
+ }
150
+ writeFileSync(mainFile, text.replace(literal, `$1'${name}'`))
151
+ return { ok: true }
152
+ }
153
+
154
+ const opening = /export\s+default\s*\{/.exec(text)
155
+ if (!opening) {
156
+ return { ok: false, reason: 'main.js has no `export default { … }` to add a name to' }
157
+ }
158
+ // The name leads the export — on lines of its own, so a multi-line export keeps
159
+ // its first line and gains a blank one between.
160
+ const at = opening.index + opening[0].length
161
+ writeFileSync(mainFile, `${text.slice(0, at)}\n ${line}\n${text.slice(at)}`)
162
+ return { ok: true }
163
+ }
164
+
165
+ /**
166
+ * Give a freshly scaffolded foundation its name, unless its main.js already has one.
167
+ *
168
+ * Called after template content is applied, because a template's own main.js
169
+ * replaces the scaffolded one — and with it the name the scaffold wrote.
170
+ *
171
+ * @param {string} foundationDir - the scaffolded package (flat layout: main.js at its root)
172
+ * @param {string|null} name
173
+ */
174
+ export function ensureFoundationName(foundationDir, name) {
175
+ if (!name) return
176
+ const mainFile = join(foundationDir, 'main.js')
177
+ if (existsSync(mainFile) && mainNamesFoundation(readFileSync(mainFile, 'utf8'))) return
178
+ writeFoundationName(mainFile, name)
179
+ }
package/src/utils/git.js CHANGED
@@ -37,12 +37,15 @@ export function siteContentRoots(siteDir) {
37
37
  // root added below already covered it and the bare `'collections.yml'` entry
38
38
  // that used to sit in this list resolved to a path no site ever had. A
39
39
  // root-level file has no directory entry standing in for it.
40
+ //
41
+ // The records folder's `folder.yml` is the reverse case: it lives in the records
42
+ // directory, which is a root below, so it needs no entry. (It was `records.yml` at
43
+ // the site root until 2026-09-21, and was listed here then.)
40
44
  const roots = new Set([
41
45
  'site.yml',
42
46
  'theme.yml',
43
47
  'head.html',
44
48
  'queries.yml',
45
- 'records.yml',
46
49
  'locales'
47
50
  ])
48
51
  let paths = {}
@@ -76,14 +76,16 @@ export async function getWorkspaceTemplateOutputs({ blank = false } = {}) {
76
76
  }
77
77
 
78
78
  /**
79
- * Scaffold a foundation from the foundation package template
79
+ * Scaffold a foundation from the foundation package template.
80
80
  *
81
- * @param {string} targetDir - Target directory for the foundation
81
+ * @param {string} targetDir
82
82
  * @param {Object} context - Template context
83
- * @param {string} context.name - Package name
83
+ * @param {string} context.name - Package name — workspace plumbing (`src`, `docs-src`)
84
+ * @param {string} [context.registryName] - The foundation's own name, written to
85
+ * main.js's `name`: what it registers as (`@org/<name>`). Omitted when none can be
86
+ * made, and `uniweb register` asks for one
84
87
  * @param {string} context.projectName - Workspace name
85
- * @param {boolean} [context.isExtension] - Whether this is an extension
86
- * @param {Object} [options] - Processing options
88
+ * @param {boolean} [context.isExtension]
87
89
  */
88
90
  export async function scaffoldFoundation(targetDir, context, options = {}) {
89
91
  registerVersions(getVersionsForTemplates())
@@ -6,7 +6,8 @@
6
6
  * - defaultLayout: Name of the default layout from src/layouts/ (optional)
7
7
  * - props: Foundation-wide props accessible via website.foundationProps
8
8
  *
9
- * Identity (name, version, description) comes from package.json.
9
+ * Name: `name` in the default export is what this foundation registers as
10
+ * (@org/<name>) — `uniweb create` adds it. The version comes from package.json.
10
11
  */
11
12
 
12
13
  // Create a layout at src/layouts/MyLayout/index.jsx
@@ -14,10 +14,19 @@
14
14
 
15
15
  {{#if isExtension}}
16
16
  export default {
17
+ {{#if registryName}}
18
+ name: '{{registryName}}',
19
+ {{/if}}
17
20
  extension: true,
18
21
  }
19
22
  {{else}}
20
23
  export default {
24
+ {{#if registryName}}
25
+ // ─── Name ───────────────────────────────────────────────────────────────────
26
+ // What this foundation registers as — @<org>/<name> — and what sites pin.
27
+ name: '{{registryName}}',
28
+
29
+ {{/if}}
21
30
  // ─── Layout ─────────────────────────────────────────────────────────────────
22
31
  // Create layouts in layouts/MyLayout/index.jsx (auto-discovered).
23
32
  // defaultLayout: 'MyLayout',
@@ -46,8 +46,9 @@ index: home
46
46
  # records/article/ → @/article (your foundation's)
47
47
  # records/std/person/ → @std/person
48
48
  # A file whose name starts with `_` is not a record.
49
- # records.yml OPTIONAL — sorts records into folders a query can slice
50
- # with `scope:`. Most sites have none:
49
+ # records/folder.yml OPTIONAL — sorts records into folders a query can slice
50
+ # with `scope:`. The one file in records/ that is not a
51
+ # record. Most sites have none:
51
52
  # - folder: archive
52
53
  # records:
53
54
  # - article/2025-*.md