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 +8 -8
- package/partials/agents.md +27 -36
- package/src/backend/asset-download.js +5 -5
- package/src/backend/foundation-bring-along.js +72 -60
- package/src/backend/service-request.js +2 -1
- package/src/backend/site-media.js +6 -6
- package/src/backend/site-sync.js +106 -64
- package/src/commands/add.js +62 -4
- package/src/commands/clone.js +1 -1
- package/src/commands/doctor.js +6 -7
- package/src/commands/i18n.js +19 -10
- package/src/commands/publish.js +10 -4
- package/src/commands/pull.js +16 -6
- package/src/commands/push.js +7 -7
- package/src/commands/register.js +126 -21
- package/src/commands/rename.js +12 -9
- package/src/commands/status.js +6 -2
- package/src/framework-index.json +7 -7
- package/src/index.js +30 -3
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +5 -2
- package/src/utils/records-guard.js +35 -28
- package/src/utils/registry-orgs.js +19 -0
- package/src/utils/scaffold.js +7 -5
- package/starter/foundation/main.js +2 -1
- package/templates/foundation/main.js.hbs +9 -0
- package/templates/site/site.yml.hbs +12 -9
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "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/
|
|
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": {
|
package/partials/agents.md
CHANGED
|
@@ -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
|
-
├──
|
|
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
|
-
│
|
|
853
|
-
│
|
|
854
|
-
|
|
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
|
-
**`
|
|
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
|
-
| `
|
|
864
|
-
| `
|
|
865
|
-
| `
|
|
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: `
|
|
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`, `
|
|
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` —
|
|
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
|
|
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
|
-
|
|
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: {
|
|
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
|
-
`
|
|
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
|
|
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: {
|
|
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
|
-
| `
|
|
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/
|
|
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
|
|
11
|
-
* author wrote. That is the whole reason the map is
|
|
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
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
241
|
-
? `${
|
|
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
|
|
265
|
-
// null when no scoped ref can be formed (then the site.yml
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
278
|
-
? `${
|
|
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
|
|
289
|
-
//
|
|
290
|
-
//
|
|
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
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
// the first release there is
|
|
298
|
-
|
|
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 =
|
|
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 `
|
|
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 `
|
|
76
|
-
// `id`+`ext` for every entry INCLUDING a `present: true`
|
|
77
|
-
// re-push of unchanged media still records identity without
|
|
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);
|
|
86
|
-
//
|
|
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,
|