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 +6 -6
- package/partials/agents.md +15 -14
- package/src/backend/foundation-bring-along.js +71 -64
- package/src/backend/site-sync.js +20 -1
- package/src/commands/add.js +62 -4
- package/src/commands/clone.js +1 -1
- package/src/commands/pull.js +13 -5
- package/src/commands/register.js +99 -12
- package/src/commands/rename.js +12 -9
- package/src/commands/status.js +6 -2
- package/src/framework-index.json +6 -6
- package/src/index.js +29 -2
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +4 -1
- 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 +3 -2
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/core": "^0.29.
|
|
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/
|
|
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": {
|
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,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
|
|
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
|
-
│
|
|
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
|
```
|
|
@@ -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
|
|
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 `
|
|
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 `
|
|
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
|
|
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
|
-
| `
|
|
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 {
|
|
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,
|
|
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,
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
246
|
-
? `${
|
|
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
|
|
270
|
-
// null when no scoped ref can be formed (then the site.yml
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
283
|
-
? `${
|
|
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
|
|
294
|
-
//
|
|
295
|
-
//
|
|
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
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
// the first release there is
|
|
303
|
-
|
|
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 =
|
|
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
|
package/src/backend/site-sync.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/add.js
CHANGED
|
@@ -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
|
*
|
package/src/commands/clone.js
CHANGED
|
@@ -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
|
)
|
package/src/commands/pull.js
CHANGED
|
@@ -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
|
-
|
|
860
|
-
//
|
|
861
|
-
|
|
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
|
-
|
|
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
|
],
|
package/src/commands/register.js
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
447
|
-
|
|
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)
|
package/src/commands/rename.js
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
636
|
-
|
|
637
|
-
|
|
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
|
package/src/commands/status.js
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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'))
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 = {}
|
package/src/utils/scaffold.js
CHANGED
|
@@ -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
|
|
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]
|
|
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
|
-
*
|
|
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
|
|
50
|
-
# with `scope:`.
|
|
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
|