uniweb 0.61.0 → 0.63.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/src/utils/args.js CHANGED
@@ -21,25 +21,17 @@
21
21
  * @returns {string | null | undefined}
22
22
  */
23
23
  /**
24
- * The org a site is created under. `--org` is the documented spelling; `--as-org`
25
- * is a working alias.
24
+ * The workspace a site command names — `--org @acme` — and, on a site's first push or
25
+ * publish, the org it is created under.
26
26
  *
27
- * `--as-org` mirrors the wire (`?as_org=`) and names an *acting capacity* — the
28
- * request is made as a member of that org, membership-gated. That is accurate, and
29
- * it is also not the name anyone reaches for: asked in one day, the backend's docs
30
- * said `--as-unit` and a second reader said `--org`; nobody produced `--as-org`.
31
- * Since the flag's main job is answering *who owns this site*, `--org` is the name
32
- * that matches the question, and the alias costs one `||` (the same shape
33
- * `--backend` already uses).
34
- *
35
- * `||`, not `??`, on purpose: a valueless `--org` falls through to `--as-org`
36
- * rather than shadowing it.
27
+ * ⛔ `--as-org` is RETIRED (2026-09-23). It mirrored the wire's `?as_org=`, which the
28
+ * `x-uniweb-workspace` header replaced; `flag-guard.js` refuses it and names `--org`.
37
29
  *
38
30
  * @param {string[]} args
39
31
  * @returns {string|null|undefined}
40
32
  */
41
33
  export function readOrgFlag(args) {
42
- return readFlagValue(args, '--org') || readFlagValue(args, '--as-org')
34
+ return readFlagValue(args, '--org')
43
35
  }
44
36
 
45
37
  /**
@@ -97,7 +89,7 @@ function editDistance(a, b) {
97
89
 
98
90
  /**
99
91
  * The closest known flag to `flag`, or null when nothing is close enough.
100
- * The threshold scales with length so `--org` → `--as-org` is offered while two
92
+ * The threshold scales with length so `--forcee` → `--force` is offered while two
101
93
  * unrelated short flags are not.
102
94
  * @param {string} flag
103
95
  * @param {string[]} known
@@ -50,12 +50,11 @@ const VIA_DEPLOY = ['--target', '--host', '--no-save']
50
50
  /**
51
51
  * Per-verb flag sets. Derived by scanning each command for dash-literals AND the
52
52
  * helpers it calls — not from the help text, which has drifted from the parser in
53
- * both directions (`--as-org` was implemented and undocumented; `--yes` is
54
- * documented on `publish` and consumed two files away).
53
+ * both directions (`--yes` is documented on `publish` and consumed two files away).
55
54
  */
56
55
  const VERBS = {
57
56
  push: [
58
- '--all', '--as-org', '--org', '--dry-run', '--force',
57
+ '--all', '--org', '--dry-run', '--force',
59
58
  '--foundation', '--output', '-o', '--personal',
60
59
  // read in utils/conformance.js and backend/site-sync.js respectively —
61
60
  // neither appears in push.js
@@ -69,7 +68,7 @@ const VERBS = {
69
68
  '--no-release', ...VIA_DEPLOY
70
69
  ],
71
70
  publish: [
72
- '--as-org', '--org', '--dry-run', '--force', '--foundation',
71
+ '--org', '--dry-run', '--force', '--foundation',
73
72
  '--personal',
74
73
  // read in utils/conformance.js, backend/site-sync.js, and
75
74
  // backend/foundation-bring-along.js — none appear in publish.js
@@ -80,15 +79,15 @@ const VERBS = {
80
79
  '--no-assets',
81
80
  '--no-records', '--no-delete', '--no-prune',
82
81
  // via backend/site-sync.js (the owner resolver) and utils/conformance.js
83
- '--yes', '--org', '--as-org', '--no-validate', ...VIA_DEPLOY
82
+ '--yes', '--org', '--no-validate', ...VIA_DEPLOY
84
83
  ],
85
84
  clone: [
86
85
  '--content-only', '--no-assets', '--no-records', '--path',
87
- '--project', '--org', '--as-org'
86
+ '--project', '--org'
88
87
  ],
89
88
  register: [
90
89
  '--dry-run', '--json', '--output', '-o',
91
- '--schema-only', '--scope', '--org', '--as-org'
90
+ '--schema-only', '--scope', '--org'
92
91
  ],
93
92
  /**
94
93
  * `forget` = remove one backend's records (`--backend <url>`), or everything a
@@ -99,7 +98,7 @@ const VERBS = {
99
98
  forget: ['--backend', '--all'],
100
99
  status: [
101
100
  '--json', '--remote', '--dry-run',
102
- '--force', '--no-verify', '--no-validate', '--yes', '--org', '--as-org',
101
+ '--force', '--no-verify', '--no-validate', '--yes', '--org',
103
102
  // inert here, reachable through the bring-along module status imports for
104
103
  // `resolveLocalFoundation` — listed per the over-approximation note above
105
104
  '--no-release', ...VIA_DEPLOY
@@ -119,7 +118,7 @@ const VERBS = {
119
118
  */
120
119
  refresh: [
121
120
  '--no-backend', '--no-git',
122
- '--as-org', '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
121
+ '--org', '--dry-run', '--no-validate', '--yes', ...VIA_DEPLOY
123
122
  ]
124
123
  }
125
124
 
@@ -213,6 +212,15 @@ export function checkFlags(verb, args = []) {
213
212
  ].join('\n')
214
213
  }
215
214
  }
215
+ // ⭐ `--as-org` is RETIRED (2026-09-23) with the `?as_org=` it mirrored — the request
216
+ // names its workspace in a header now, and `--org` is the one spelling.
217
+ if (flag === '--as-org' && all.includes('--org')) {
218
+ return {
219
+ flag,
220
+ suggestion: '--org',
221
+ message: `\`--as-org\` is retired — use \`--org @org\` (e.g. \`uniweb ${verb} --org @acme\`).`
222
+ }
223
+ }
216
224
  const suggestion = didYouMean(flag, all)
217
225
  const lines = [`Unknown flag \`${flag}\` for \`uniweb ${verb}\`.`]
218
226
  if (suggestion) lines.push(` Did you mean \`${suggestion}\`?`)
@@ -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 = {}
@@ -61,8 +61,9 @@ export function bareHandle(scope) {
61
61
 
62
62
  /**
63
63
  * A publish scope in the one form the registry names things by — `@acme` — from any
64
- * spelling `--scope` or `package.json::uniweb.scope` may carry it in (`@acme`, `acme`,
65
- * `@acme/…`). Null when there is no handle in it.
64
+ * spelling `--scope` or a schemas-only package's `package.json::uniweb.scope` may carry
65
+ * it in (`@acme`, `acme`, `@acme/…`). Null when there is no handle in it. (A
66
+ * foundation's scope is part of its name, `@acme/marketing`, and needs no reading.)
66
67
  *
67
68
  * ⛔ Every name built from a scope goes through this. The `.uwx` assembly
68
69
  * (`@uniweb/build`'s `buildRegistryPackage`) has always accepted `acme` as well as
@@ -76,14 +76,16 @@ export async function getWorkspaceTemplateOutputs({ blank = false } = {}) {
76
76
  }
77
77
 
78
78
  /**
79
- * Scaffold a foundation from the foundation package template
79
+ * Scaffold a foundation from the foundation package template.
80
80
  *
81
- * @param {string} targetDir - Target directory for the foundation
81
+ * @param {string} targetDir
82
82
  * @param {Object} context - Template context
83
- * @param {string} context.name - Package name
83
+ * @param {string} context.name - Package name — workspace plumbing (`src`, `docs-src`)
84
+ * @param {string} [context.registryName] - The foundation's own name, written to
85
+ * main.js's `name`: what it registers as (`@org/<name>`). Omitted when none can be
86
+ * made, and `uniweb register` asks for one
84
87
  * @param {string} context.projectName - Workspace name
85
- * @param {boolean} [context.isExtension] - Whether this is an extension
86
- * @param {Object} [options] - Processing options
88
+ * @param {boolean} [context.isExtension]
87
89
  */
88
90
  export async function scaffoldFoundation(targetDir, context, options = {}) {
89
91
  registerVersions(getVersionsForTemplates())
@@ -51,6 +51,7 @@
51
51
  * demonstrated.**
52
52
  */
53
53
 
54
+ import { WORKSPACE_HEADER } from '../backend/client.js'
54
55
  import { createHash } from 'node:crypto'
55
56
  import { humanBytes } from './bytes.js'
56
57
  import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
@@ -62,6 +63,9 @@ import { fetchWithRetry, isTransientStatus } from './fetch-retry.js'
62
63
  * @param {string} opts.apiBase - backend origin
63
64
  * @param {string} opts.token - bearer, used on the direct arm only
64
65
  * @param {string} opts.siteUuid - the site these files belong to (path segment)
66
+ * @param {string|null} [opts.workspace] - the workspace the requests name, `@handle`
67
+ * (`x-uniweb-workspace`) — on the plan and on every DIRECT PUT, never on a presigned
68
+ * one, which is self-authorizing and must carry nothing it was not signed with
65
69
  * @param {{ data: Record<string, unknown> }|null} opts.ball - source of the set:
66
70
  * `{ "<relpath under dist/data>": <json> }`, media refs already rewritten
67
71
  * @param {(m: string) => void} [opts.onProgress]
@@ -72,9 +76,13 @@ export async function uploadSiteData({
72
76
  apiBase,
73
77
  token,
74
78
  siteUuid,
79
+ workspace = null,
75
80
  ball,
76
81
  onProgress = () => {}
77
82
  }) {
83
+ // A plan made with the header mints BARE PUT urls: the PUT names its workspace with
84
+ // the header like every other request (backend, 2026-09-23).
85
+ const workspaceHeader = workspace ? { [WORKSPACE_HEADER]: workspace } : {}
78
86
  // ⛔ An EMPTY set still posts a plan, and that is the whole point of this lane
79
87
  // being a manifest rather than a stream of files.
80
88
  //
@@ -131,7 +139,8 @@ export async function uploadSiteData({
131
139
  method: 'POST',
132
140
  headers: {
133
141
  'Content-Type': 'application/json',
134
- Authorization: `Bearer ${token}`
142
+ Authorization: `Bearer ${token}`,
143
+ ...workspaceHeader
135
144
  },
136
145
  body: JSON.stringify({
137
146
  files: files.map(({ path, content_type, size, sha256 }) => ({
@@ -172,7 +181,9 @@ export async function uploadSiteData({
172
181
  // PUT is a bearer-authed backend route; a presigned URL is self-authorizing
173
182
  // and must NOT carry a foreign bearer, which can break signature validation.
174
183
  const authHeaders =
175
- plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
184
+ plan.mode === 'presigned'
185
+ ? {}
186
+ : { Authorization: `Bearer ${token}`, ...workspaceHeader }
176
187
 
177
188
  const uploaded = []
178
189
  const failed = []
@@ -6,7 +6,8 @@
6
6
  * - defaultLayout: Name of the default layout from src/layouts/ (optional)
7
7
  * - props: Foundation-wide props accessible via website.foundationProps
8
8
  *
9
- * Identity (name, version, description) comes from package.json.
9
+ * Name: `name` in the default export is what this foundation registers as
10
+ * (@org/<name>) — `uniweb create` adds it. The version comes from package.json.
10
11
  */
11
12
 
12
13
  // Create a layout at src/layouts/MyLayout/index.jsx
@@ -14,10 +14,19 @@
14
14
 
15
15
  {{#if isExtension}}
16
16
  export default {
17
+ {{#if registryName}}
18
+ name: '{{registryName}}',
19
+ {{/if}}
17
20
  extension: true,
18
21
  }
19
22
  {{else}}
20
23
  export default {
24
+ {{#if registryName}}
25
+ // ─── Name ───────────────────────────────────────────────────────────────────
26
+ // What this foundation registers as — @<org>/<name> — and what sites pin.
27
+ name: '{{registryName}}',
28
+
29
+ {{/if}}
21
30
  // ─── Layout ─────────────────────────────────────────────────────────────────
22
31
  // Create layouts in layouts/MyLayout/index.jsx (auto-discovered).
23
32
  // defaultLayout: 'MyLayout',
@@ -46,8 +46,9 @@ index: home
46
46
  # records/article/ → @/article (your foundation's)
47
47
  # records/std/person/ → @std/person
48
48
  # A file whose name starts with `_` is not a record.
49
- # records.yml OPTIONAL — sorts records into folders a query can slice
50
- # with `scope:`. Most sites have none:
49
+ # records/folder.yml OPTIONAL — sorts records into folders a query can slice
50
+ # with `scope:`. The one file in records/ that is not a
51
+ # record. Most sites have none:
51
52
  # - folder: archive
52
53
  # records:
53
54
  # - article/2025-*.md