@uniweb/build 0.58.0 → 0.59.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/src/foundation-name.js +26 -0
- package/src/index.js +1 -0
- package/src/runtime-schema.js +4 -2
- package/src/schema.js +33 -5
- package/src/site/foundation-ref.js +15 -2
- package/src/site/queries-config.js +1 -1
- package/src/uwx/index.js +3 -1
- package/src/uwx/records-project.js +29 -36
- package/src/uwx/records.js +20 -10
- package/src/uwx/registry-package.js +33 -6
- package/src/uwx/self-scope.js +110 -23
- package/src/uwx/site-project.js +5 -2
- package/src/uwx/site.js +42 -70
- package/src/uwx/sync-package.js +18 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
+
"@uniweb/schemas": "^0.3.4",
|
|
61
|
+
"@uniweb/semantic-parser": "^1.4.1",
|
|
60
62
|
"@uniweb/content-writer": "^0.3.4",
|
|
61
|
-
"@uniweb/content-reader": "^1.2.5",
|
|
62
63
|
"@uniweb/projections": "^0.7.3",
|
|
63
|
-
"@uniweb/
|
|
64
|
-
"@uniweb/schemas": "^0.3.3",
|
|
64
|
+
"@uniweb/content-reader": "^1.2.5",
|
|
65
65
|
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
@@ -74,8 +74,8 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/runtime": "^0.27.4",
|
|
78
|
+
"@uniweb/core": "^0.29.8"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/foundation-name.js
CHANGED
|
@@ -17,6 +17,13 @@
|
|
|
17
17
|
// ⛔ `package.json::uniweb.id` WAS A REGISTRY-NAME OVERRIDE, 2026-07-17 → 2026-09-21.
|
|
18
18
|
// It is retired: the name lives in `main.js`, and two homes for one fact is how `push`
|
|
19
19
|
// and `register` came to look the same foundation up under different names.
|
|
20
|
+
//
|
|
21
|
+
// ⭐ THE SCOPE IS PART OF THE NAME (2026-09-22 [Diego]) — `@acme/marketing` — so the
|
|
22
|
+
// org a foundation registers under lives where the rest of its name does. A bare name
|
|
23
|
+
// has no scope yet; `uniweb register` gives it one and writes it into `main.js`.
|
|
24
|
+
// ⛔ `package.json::uniweb.scope` held it until then, a second home for half of one
|
|
25
|
+
// fact, and the halves disagreed: a foundation named `@acme/fnd` registered its data
|
|
26
|
+
// schemas under whatever `uniweb.scope` said (measured: `@proximify/member`).
|
|
20
27
|
|
|
21
28
|
/** Names that say where the code lives, not what the foundation is. */
|
|
22
29
|
export const FORBIDDEN_FOUNDATION_NAMES = new Set(['src', 'foundation'])
|
|
@@ -61,3 +68,22 @@ export function checkFoundationName(name) {
|
|
|
61
68
|
}
|
|
62
69
|
return null
|
|
63
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A foundation's name in its two halves: the scope it registers under, and the name
|
|
74
|
+
* within that scope. `@acme/marketing` → `{ scope: '@acme', bare: 'marketing' }`;
|
|
75
|
+
* `marketing` → `{ scope: null, bare: 'marketing' }` — a name that has not registered
|
|
76
|
+
* yet, since `register` writes the scope it chooses into the name.
|
|
77
|
+
*
|
|
78
|
+
* ⭐ The scope is what a foundation's own `@/x` data schemas resolve into — at
|
|
79
|
+
* register, and in a site's records and queries, which name those same Models.
|
|
80
|
+
*
|
|
81
|
+
* @param {unknown} name
|
|
82
|
+
* @returns {{ scope: string|null, bare: string|null }} `scope` as `@org`
|
|
83
|
+
*/
|
|
84
|
+
export function splitFoundationName(name) {
|
|
85
|
+
const text = typeof name === 'string' ? name.trim() : ''
|
|
86
|
+
if (!text) return { scope: null, bare: null }
|
|
87
|
+
const scoped = /^@([^/]+)\/([^/]+)$/.exec(text)
|
|
88
|
+
return scoped ? { scope: `@${scoped[1]}`, bare: scoped[2] } : { scope: null, bare: text }
|
|
89
|
+
}
|
package/src/index.js
CHANGED
package/src/runtime-schema.js
CHANGED
|
@@ -194,8 +194,10 @@ function leanDataSchema(value, dataSchemaMap) {
|
|
|
194
194
|
if (ref) {
|
|
195
195
|
const resolved = dataSchemaMap[ref]
|
|
196
196
|
// ⛔ `flatRecordFields`, NOT `resolved.fields`. `dataSchemaMap` holds each
|
|
197
|
-
// schema
|
|
198
|
-
//
|
|
197
|
+
// schema NORMALIZED but still in the form it was authored in — `fields:` or
|
|
198
|
+
// `sections:` — because only lowering (`uwx/data-schema.js`) folds the two
|
|
199
|
+
// forms into one. (This said "AS AUTHORED" until 2026-09-22; aliases and
|
|
200
|
+
// `many:` are already folded here.) `fields:` at the top is the
|
|
199
201
|
// SUGAR for a one-section model; a sections-form schema has no such key, so
|
|
200
202
|
// reading it directly returned null for every `@std/*` binding and the
|
|
201
203
|
// section's `data:` declaration supplied no field defaults at all. Silent:
|
package/src/schema.js
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
deriveRecordsSupport,
|
|
24
24
|
unnameableIn,
|
|
25
25
|
} from './foundation/derive-supports.js'
|
|
26
|
-
import { foundationNameOf } from './foundation-name.js'
|
|
26
|
+
import { foundationNameOf, splitFoundationName } from './foundation-name.js'
|
|
27
27
|
import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
|
|
28
28
|
|
|
29
29
|
// Component meta file name
|
|
@@ -191,6 +191,14 @@ export async function loadPackageJson(srcDir) {
|
|
|
191
191
|
`\`name\` in main.js's default export (name: '${pkg.uniweb.id}').`
|
|
192
192
|
)
|
|
193
193
|
}
|
|
194
|
+
// ⛔ `uniweb.scope` likewise (retired 2026-09-22) — the scope is part of the name.
|
|
195
|
+
// The build never read it; `uniweb register` refuses it, naming the line to write.
|
|
196
|
+
if (pkg.uniweb?.scope !== undefined) {
|
|
197
|
+
console.warn(
|
|
198
|
+
`[uniweb] ${packagePath}: \`uniweb.scope\` is no longer read — a foundation's scope is ` +
|
|
199
|
+
`part of its name in main.js's default export (name: '@<org>/<name>').`
|
|
200
|
+
)
|
|
201
|
+
}
|
|
194
202
|
return {
|
|
195
203
|
name: pkg.name,
|
|
196
204
|
version: pkg.version,
|
|
@@ -212,9 +220,10 @@ export async function loadPackageJson(srcDir) {
|
|
|
212
220
|
* (`foundation-name.js`) — for `uniweb register` and for `push` / `publish` looking
|
|
213
221
|
* the foundation up, which need it before a build, or without one.
|
|
214
222
|
*
|
|
215
|
-
* ⛔ Refuses `package.json::uniweb.id` (retired 2026-09-21)
|
|
216
|
-
*
|
|
217
|
-
*
|
|
223
|
+
* ⛔ Refuses `package.json::uniweb.id` (retired 2026-09-21) and `uniweb.scope`
|
|
224
|
+
* (retired 2026-09-22 — the scope is part of the name). This is the reader whose
|
|
225
|
+
* answer registers, so a leftover stops here rather than registering the foundation
|
|
226
|
+
* under a different name than the one it was given.
|
|
218
227
|
*
|
|
219
228
|
* @param {string} foundationDir - the foundation package's root
|
|
220
229
|
* @returns {Promise<{ name: string|null, source: 'main.js'|'package.json'|null, mainFile: string }>}
|
|
@@ -238,12 +247,31 @@ export async function readFoundationName(foundationDir) {
|
|
|
238
247
|
}
|
|
239
248
|
const srcDir = join(foundationDir, resolveFoundationSrcDir(foundationDir))
|
|
240
249
|
const config = await loadFoundationConfig(srcDir)
|
|
250
|
+
const read = foundationNameOf({ config, pkg })
|
|
251
|
+
// ⛔ `package.json::uniweb.scope` is retired (2026-09-22): a foundation's scope is
|
|
252
|
+
// part of its name. Refused here, the reader whose answer registers, because a
|
|
253
|
+
// leftover would register the name and the data schemas under two different orgs.
|
|
254
|
+
if (pkg.uniweb?.scope !== undefined) {
|
|
255
|
+
throw new Error(retiredScopeMessage(pkgPath, pkg.uniweb.scope, read.name))
|
|
256
|
+
}
|
|
241
257
|
return {
|
|
242
|
-
...
|
|
258
|
+
...read,
|
|
243
259
|
mainFile: join(srcDir, FOUNDATION_FILE_NAMES[0]),
|
|
244
260
|
}
|
|
245
261
|
}
|
|
246
262
|
|
|
263
|
+
/**
|
|
264
|
+
* What to say about a leftover `package.json::uniweb.scope`: the `main.js` line that
|
|
265
|
+
* replaces it, or — when `main.js` already scopes the name — that it can go.
|
|
266
|
+
*/
|
|
267
|
+
function retiredScopeMessage(pkgPath, scope, name) {
|
|
268
|
+
const { scope: nameScope, bare } = splitFoundationName(name)
|
|
269
|
+
const intro = `${pkgPath}: \`uniweb.scope\` is no longer read — a foundation's scope is part of its name, \`name\` in main.js's default export.`
|
|
270
|
+
if (nameScope) return `${intro} main.js already names it '${name}', so remove uniweb.scope.`
|
|
271
|
+
const handle = String(scope ?? '').replace(/^@/, '').replace(/\/.*$/, '') || '<org>'
|
|
272
|
+
return `${intro} Move it there (name: '@${handle}/${bare || '<name>'}') and remove uniweb.scope.`
|
|
273
|
+
}
|
|
274
|
+
|
|
247
275
|
/**
|
|
248
276
|
* Load the foundation's declarations from `src/main.js`.
|
|
249
277
|
*
|
|
@@ -31,6 +31,20 @@
|
|
|
31
31
|
import { existsSync, readFileSync } from 'node:fs'
|
|
32
32
|
import { resolve, join } from 'node:path'
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A catalog ref — `@org/name@version` — in its parts, or null for anything else.
|
|
36
|
+
*
|
|
37
|
+
* The grammar lives here once: `detectFoundationType` refuses a ref for a build, and a
|
|
38
|
+
* site's sync reads the ref's scope to qualify its `@/x` refs (`uwx/self-scope.js`).
|
|
39
|
+
*
|
|
40
|
+
* @param {unknown} value
|
|
41
|
+
* @returns {{ scope: string, name: string, version: string }|null} `scope` as `@org`
|
|
42
|
+
*/
|
|
43
|
+
export function parseCatalogRef(value) {
|
|
44
|
+
const m = typeof value === 'string' ? /^@([a-z0-9_-]+)\/([a-z0-9_-]+)@(.+)$/.exec(value) : null
|
|
45
|
+
return m ? { scope: `@${m[1]}`, name: m[2], version: m[3] } : null
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
/**
|
|
35
49
|
* Detect foundation type from the foundation config value
|
|
36
50
|
*
|
|
@@ -104,8 +118,7 @@ export function detectFoundationType(foundation, siteRoot) {
|
|
|
104
118
|
// match the documented backend selection — so `--backend`, `uniweb login
|
|
105
119
|
// --backend` and the documented env var all left it pinned — and the artifact
|
|
106
120
|
// names were the pre-`entry.js` ones the build stopped emitting.
|
|
107
|
-
|
|
108
|
-
if (orgScopedMatch) {
|
|
121
|
+
if (parseCatalogRef(name)) {
|
|
109
122
|
throw new Error(
|
|
110
123
|
[
|
|
111
124
|
`Foundation "${name}" is a catalog ref, and a build cannot resolve it to a URL.`,
|
|
@@ -368,7 +368,7 @@ async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
|
|
|
368
368
|
* The `deferred:` a schema implies — every record field its **brief** does not name.
|
|
369
369
|
*
|
|
370
370
|
* ⛔ ONE IMPLEMENTATION, TWO CALLERS, and that is the point. `deriveDeferredFromSchemas`
|
|
371
|
-
* above uses it to FILL an unstated `deferred:`; `uwx/
|
|
371
|
+
* above uses it to FILL an unstated `deferred:`; `uwx/records-project.js` uses it to
|
|
372
372
|
* RECOGNIZE a derived value on the way back in, so a pull does not write a derivation
|
|
373
373
|
* into the author's file as though they had typed it.
|
|
374
374
|
*
|
package/src/uwx/index.js
CHANGED
|
@@ -43,7 +43,6 @@ export {
|
|
|
43
43
|
siteProjectToDocument,
|
|
44
44
|
emitSiteSyncPackage,
|
|
45
45
|
writeSiteEntityUuid,
|
|
46
|
-
writeSiteOrg,
|
|
47
46
|
extensionDeclaration,
|
|
48
47
|
isExtensionUrl,
|
|
49
48
|
isSiteRelativeExtensionUrl,
|
|
@@ -64,6 +63,9 @@ export {
|
|
|
64
63
|
folderToFolderYml,
|
|
65
64
|
findRecordFileByUuid,
|
|
66
65
|
} from './records-project.js'
|
|
66
|
+
// The scope a site's `@/x` refs resolve into — its foundation's. A pull resolves it
|
|
67
|
+
// up front and hands it to the synchronous projections above.
|
|
68
|
+
export { siteSelfScope } from './self-scope.js'
|
|
67
69
|
export {
|
|
68
70
|
siteInfoToConfig,
|
|
69
71
|
sectionRecordToFile,
|
|
@@ -40,7 +40,8 @@ import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './quer
|
|
|
40
40
|
import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
|
|
41
41
|
import { folderYmlPath } from '../site/records-config.js'
|
|
42
42
|
import { isContentBodyField } from './data-schema.js'
|
|
43
|
-
import { unresolveSelfScope } from './self-scope.js'
|
|
43
|
+
import { unresolveSelfScope, refuseOrgOption } from './self-scope.js'
|
|
44
|
+
import { parseCatalogRef } from '../site/foundation-ref.js'
|
|
44
45
|
import { unwrapLocalized } from './backfill.js'
|
|
45
46
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
46
47
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
@@ -151,8 +152,8 @@ function indexFolder(folderDoc) {
|
|
|
151
152
|
// `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
|
|
152
153
|
// a pulled record would land somewhere the next build reads as a different
|
|
153
154
|
// schema — silently, because both paths are well-formed.
|
|
154
|
-
function recordDirFor(recordsRoot, model,
|
|
155
|
-
const dirs = poolDirsForSchema(unresolveSelfScope(model,
|
|
155
|
+
function recordDirFor(recordsRoot, model, scope) {
|
|
156
|
+
const dirs = poolDirsForSchema(unresolveSelfScope(model, scope))
|
|
156
157
|
return dirs ? join(recordsRoot, ...dirs) : null
|
|
157
158
|
}
|
|
158
159
|
|
|
@@ -173,16 +174,6 @@ function locate(document, folderIndex) {
|
|
|
173
174
|
return fromFolder || null
|
|
174
175
|
}
|
|
175
176
|
|
|
176
|
-
/** The site's own org on `backend` — its `site.org` in `sync.json` — bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
|
|
177
|
-
// ⛔ `site.yml::$org` IS GONE (2026-09-20) — the org lives in
|
|
178
|
-
// `sync.json::backends.<origin>.site.org`. This read `site.yml` and, once step 4
|
|
179
|
-
// moved the key, silently returned null: every `@org/x` model a pull met would have
|
|
180
|
-
// been placed back as if the site had no org. No test spanned it.
|
|
181
|
-
function readSiteOrg(siteRoot, backend) {
|
|
182
|
-
const org = backend ? readBackendState(siteRoot, backend).site?.org : null
|
|
183
|
-
return typeof org === 'string' && org ? org : null
|
|
184
|
-
}
|
|
185
|
-
|
|
186
177
|
// Skip undefined when copying optional fields into a projected declaration.
|
|
187
178
|
function setIf(obj, key, value) {
|
|
188
179
|
if (value !== undefined) obj[key] = value
|
|
@@ -239,15 +230,15 @@ function isDerivedDeferred(d, dataSchemas) {
|
|
|
239
230
|
return d.deferred.every((f) => a.has(f))
|
|
240
231
|
}
|
|
241
232
|
|
|
242
|
-
function declToFileShape(wire, dataSchemas = null,
|
|
233
|
+
function declToFileShape(wire, dataSchemas = null, scope = null) {
|
|
243
234
|
// ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
|
|
244
|
-
// `schema`. The push qualifies a foundation-relative `@/x` to `@
|
|
235
|
+
// `schema`. The push qualifies a foundation-relative `@/x` to `@scope/x`
|
|
245
236
|
// (`site.js::queriesNested`), and both checks below are keyed by the author's
|
|
246
|
-
// `@/x`: against `@
|
|
237
|
+
// `@/x`: against `@scope/x` the query-name default would never match — writing an
|
|
247
238
|
// explicit schema the author never had — and the derived-`deferred` lookup would
|
|
248
239
|
// miss, persisting a derivation into their file (the 2026-08-29 defect).
|
|
249
|
-
const d =
|
|
250
|
-
? { ...wire, schema: unresolveSelfScope(wire.schema,
|
|
240
|
+
const d = scope && typeof wire.schema === 'string'
|
|
241
|
+
? { ...wire, schema: unresolveSelfScope(wire.schema, scope) }
|
|
251
242
|
: wire
|
|
252
243
|
const name = d.name || d.$id
|
|
253
244
|
const decl = {}
|
|
@@ -329,14 +320,14 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
|
|
|
329
320
|
* @param {object} params
|
|
330
321
|
* @param {object} params.document - a site-content `$`-document (`{ queries }`)
|
|
331
322
|
* @param {string} params.siteRoot
|
|
332
|
-
* @param {string} [params.
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
336
|
-
* @param {string|null} [params.backend] - whose recorded org is that default
|
|
323
|
+
* @param {string|null} [params.scope] - the scope the push qualified a `schema` from
|
|
324
|
+
* `@/x` with — the site's foundation's — so it is written back as `@/x`.
|
|
325
|
+
* Defaults to the scope of the foundation ref the pulled document carries
|
|
326
|
+
* (`info.foundation`, the pinned `@org/name@version`), which is that scope.
|
|
337
327
|
* @returns {{ collections?: 'updated'|'unchanged' }}
|
|
338
328
|
*/
|
|
339
|
-
export function declarationsToQueriesYml({ document, siteRoot,
|
|
329
|
+
export function declarationsToQueriesYml({ document, siteRoot, scope, ...rest }) {
|
|
330
|
+
refuseOrgOption(rest, 'declarationsToQueriesYml')
|
|
340
331
|
const decls = Array.isArray(document?.queries) ? document.queries : []
|
|
341
332
|
const report = {}
|
|
342
333
|
if (decls.length === 0) return report
|
|
@@ -354,11 +345,12 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
|
|
|
354
345
|
siteYml = null
|
|
355
346
|
}
|
|
356
347
|
const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
|
|
357
|
-
const
|
|
348
|
+
const selfScope =
|
|
349
|
+
scope !== undefined ? scope : (parseCatalogRef(document?.info?.foundation)?.scope ?? null)
|
|
358
350
|
|
|
359
351
|
const queries = {}
|
|
360
352
|
for (const d of decls) {
|
|
361
|
-
const { name, decl } = declToFileShape(d, dataSchemas,
|
|
353
|
+
const { name, decl } = declToFileShape(d, dataSchemas, selfScope)
|
|
362
354
|
if (!name) continue
|
|
363
355
|
queries[name] = decl
|
|
364
356
|
}
|
|
@@ -469,20 +461,21 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
469
461
|
* @param {object} params.opts
|
|
470
462
|
* @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
|
|
471
463
|
* - resolve a Model's data-schema declaration by name (`$model`).
|
|
472
|
-
* @param {string} [params.opts.
|
|
473
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
464
|
+
* @param {string|null} [params.opts.scope] - the scope the push qualified a record's
|
|
465
|
+
* `@/x` Model with — the site's foundation's — so a `@scope/x` model is placed
|
|
466
|
+
* back where the author wrote it. The caller resolves it up front, as it does
|
|
467
|
+
* Models (`siteSelfScope`); absent, a model is placed under its own scope.
|
|
468
|
+
* @param {string} [params.opts.backend] - whose record map to read and extend
|
|
477
469
|
* @param {string} [params.opts.sourceLocale]
|
|
478
470
|
* @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
|
|
479
471
|
*/
|
|
480
472
|
export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
|
|
481
473
|
const { resolveDeclaration, sourceLocale = 'en' } = opts
|
|
482
|
-
|
|
483
|
-
//
|
|
484
|
-
// `
|
|
485
|
-
|
|
474
|
+
refuseOrgOption(opts, 'recordsToProject')
|
|
475
|
+
// The scope the push qualified `@/x` with — the site's foundation's — so a
|
|
476
|
+
// `@scope/x` model is placed back where the author wrote it. ⛔ It was the site
|
|
477
|
+
// owner's org, read from `sync.json`, until 2026-09-22 (`self-scope.js`).
|
|
478
|
+
const selfScope = opts.scope ?? null
|
|
486
479
|
if (typeof resolveDeclaration !== 'function') {
|
|
487
480
|
throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
|
|
488
481
|
}
|
|
@@ -525,7 +518,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
525
518
|
continue
|
|
526
519
|
}
|
|
527
520
|
|
|
528
|
-
const poolDir = recordDirFor(recordsRoot, document.$model,
|
|
521
|
+
const poolDir = recordDirFor(recordsRoot, document.$model, selfScope)
|
|
529
522
|
if (!poolDir) {
|
|
530
523
|
skipped.push({
|
|
531
524
|
uuid: document.$uuid,
|
package/src/uwx/records.js
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
} from '../site/entity-pool.js'
|
|
58
58
|
import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
|
|
59
59
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
60
|
-
import { resolveSelfScope } from './self-scope.js'
|
|
60
|
+
import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
61
61
|
import { sha256Hex, toJsonBuffer } from './manifest.js'
|
|
62
62
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
63
63
|
import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
|
|
@@ -554,6 +554,9 @@ function resolveDeclaration(schema, modelName) {
|
|
|
554
554
|
* `@uniweb/data-schema` declaration (or null). The verb wires this to the
|
|
555
555
|
* backend's Model-read route. Without it, the local foundation is required.
|
|
556
556
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale
|
|
557
|
+
* @param {string|null} [opts.scope] - the scope a record's `@/x` Model resolves into.
|
|
558
|
+
* Defaults to the site's foundation's (`siteSelfScope`); a caller that has it
|
|
559
|
+
* already passes it, so one emit reads it once.
|
|
557
560
|
* @returns {Promise<{ entities: object[], index: object[], warnings: string[],
|
|
558
561
|
* schemaless: Array<{name: string, model: string}>, colConfig: object,
|
|
559
562
|
* folder: object, recordsDirExists: boolean }>}
|
|
@@ -585,6 +588,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
585
588
|
// twice and the duplicate check refused the push (measured: "appears in more than
|
|
586
589
|
// one query"), and a record no query read was never pushed. Records are walked
|
|
587
590
|
// once, by the schema their folder declares.
|
|
591
|
+
refuseOrgOption(opts, 'uwx/records')
|
|
588
592
|
const pool = await readEntityPool(siteRoot)
|
|
589
593
|
const recordsCfg = await readRecordsConfig(siteRoot, { dir: pool.dir })
|
|
590
594
|
if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
|
|
@@ -657,19 +661,25 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
657
661
|
//
|
|
658
662
|
// ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
|
|
659
663
|
// (`site.js::queriesNested`): a query's `schema` must name exactly the Model
|
|
660
|
-
// these records are stored under, so both go through one function with one
|
|
664
|
+
// these records are stored under, so both go through one function with one scope.
|
|
665
|
+
//
|
|
666
|
+
// ⭐ THE SCOPE IS THE FOUNDATION'S — the one `register` stored these Models under —
|
|
667
|
+
// never the site owner's (2026-09-22; `self-scope.js` has the record).
|
|
668
|
+
const scope =
|
|
669
|
+
opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
|
|
661
670
|
const warnedUnscoped = new Set()
|
|
662
671
|
const modelFor = (schema, where) => {
|
|
663
|
-
const modelName = resolveSelfScope(schema,
|
|
664
|
-
// Unresolvable `@/` — no
|
|
665
|
-
// probe on a never-
|
|
666
|
-
// the backend's refusal names a missing Model and cannot name this cause.
|
|
672
|
+
const modelName = resolveSelfScope(schema, scope)
|
|
673
|
+
// Unresolvable `@/` — the foundation has no scope yet. Ship it rather than
|
|
674
|
+
// throwing (a `status` probe on a never-registered foundation must still count),
|
|
675
|
+
// but say so: the backend's refusal names a missing Model and cannot name this cause.
|
|
667
676
|
if (modelName === schema && typeof schema === 'string' && schema.startsWith('@/') && !warnedUnscoped.has(schema)) {
|
|
668
677
|
warnedUnscoped.add(schema)
|
|
669
678
|
warnings.push(
|
|
670
|
-
`${where}: \`${schema}\` is foundation-relative and no
|
|
679
|
+
`${where}: \`${schema}\` is foundation-relative and the foundation has no scope yet, ` +
|
|
671
680
|
`so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
|
|
672
|
-
`
|
|
681
|
+
`A foundation's scope is part of its name — \`name: '@org/<name>'\` in its main.js, ` +
|
|
682
|
+
`which \`uniweb register\` writes.`
|
|
673
683
|
)
|
|
674
684
|
}
|
|
675
685
|
return modelName
|
|
@@ -863,8 +873,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
863
873
|
? !unresolved.has(schema)
|
|
864
874
|
: Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
|
|
865
875
|
if (!resolved) {
|
|
866
|
-
if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema,
|
|
867
|
-
schemaless.push({ name, model: resolveSelfScope(schema,
|
|
876
|
+
if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
|
|
877
|
+
schemaless.push({ name, model: resolveSelfScope(schema, scope) })
|
|
868
878
|
continue
|
|
869
879
|
}
|
|
870
880
|
if (!hasRecords) {
|
|
@@ -17,16 +17,17 @@
|
|
|
17
17
|
* `@uniweb/data-schema` entities, no foundation-schema — for a schemas-only
|
|
18
18
|
* package (the standard schemas under `@std`, or an org's own `@org/schemas`).
|
|
19
19
|
*
|
|
20
|
-
* Scope:
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* Scope: a foundation whose name is scoped (`@acme/fnd`) registers under that scope —
|
|
21
|
+
* its name and its schemas' own `@/x` alike. A bare name takes `scope` ('@acme' or
|
|
22
|
+
* 'acme'). With neither, names stay `@/x` (local preview / dry-run). A schemas-only
|
|
23
|
+
* package has no name to carry one, so it always takes `scope`. See `uwx-format.md`.
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
26
27
|
import { join } from 'node:path'
|
|
27
28
|
|
|
28
29
|
import { toDataSchemaDeclaration } from './data-schema.js'
|
|
29
|
-
import { checkFoundationName } from '../foundation-name.js'
|
|
30
|
+
import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
|
|
30
31
|
|
|
31
32
|
const FOUNDATION_SCHEMA = '@uniweb/foundation-schema'
|
|
32
33
|
const DATA_SCHEMA = '@uniweb/data-schema'
|
|
@@ -46,7 +47,9 @@ try {
|
|
|
46
47
|
* @param {Object} params
|
|
47
48
|
* @param {Object} params.schema - parsed `dist/meta/schema.json`.
|
|
48
49
|
* @param {string} [params.foundationDir] - foundation root, for the `i18n/` bundles.
|
|
49
|
-
* @param {string} [params.scope] - org scope (`@acme` or `acme`) resolving `@/x` -> `@acme/x
|
|
50
|
+
* @param {string} [params.scope] - org scope (`@acme` or `acme`) resolving `@/x` -> `@acme/x`,
|
|
51
|
+
* for a foundation whose name is bare. A scoped name's own scope is used instead, and
|
|
52
|
+
* a `scope` that contradicts it throws.
|
|
50
53
|
* @param {Object} [params.exporter] - `{ tool, version, instance }` for the envelope.
|
|
51
54
|
* @param {string} [params.exportedAt] - ISO timestamp (default: now).
|
|
52
55
|
* @param {string} [params.runtime] - the `@uniweb/runtime` version this build
|
|
@@ -70,7 +73,10 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
|
|
|
70
73
|
// own `@/x`), each resolved to the concrete publish scope. Shared refs
|
|
71
74
|
// (`@std/x`, `@other/x`) are named in the foundation's data-schemas.refs but
|
|
72
75
|
// their declarations are not bundled — already published.
|
|
73
|
-
const { entities: dataSchemaEntities, scoped, org } = buildDataSchemaEntities(
|
|
76
|
+
const { entities: dataSchemaEntities, scoped, org } = buildDataSchemaEntities(
|
|
77
|
+
dataSchemas,
|
|
78
|
+
registrationScope(self.name, scope)
|
|
79
|
+
)
|
|
74
80
|
|
|
75
81
|
const foundationEntity = {
|
|
76
82
|
model: FOUNDATION_SCHEMA,
|
|
@@ -108,6 +114,27 @@ export function buildSchemaOnlyPackage({ schemas, scope, exporter, exportedAt }
|
|
|
108
114
|
return wrapEntities(entities, exporter, exportedAt)
|
|
109
115
|
}
|
|
110
116
|
|
|
117
|
+
// The scope a foundation registers under: the one in its name, else `scope`.
|
|
118
|
+
//
|
|
119
|
+
// ⭐ A SCOPED NAME IS THE SCOPE (2026-09-22). `@acme/fnd` registers under `@acme` —
|
|
120
|
+
// the foundation and its data schemas both, because a site's records name those
|
|
121
|
+
// schemas by the same scope. ⛔ Until then the schemas took `scope` while the name
|
|
122
|
+
// kept its own, so one foundation registered `@acme/fnd` beside `@proximify/member`.
|
|
123
|
+
// A `scope` that contradicts the name is refused rather than chosen between: which
|
|
124
|
+
// org was meant is not ours to guess.
|
|
125
|
+
function registrationScope(name, scope) {
|
|
126
|
+
const { scope: nameScope } = splitFoundationName(name)
|
|
127
|
+
const handle = scope ? String(scope).replace(/^@/, '').replace(/\/.*$/, '') : ''
|
|
128
|
+
const given = handle ? `@${handle}` : null
|
|
129
|
+
if (nameScope && given && given !== nameScope) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`buildRegistryPackage: the foundation is named ${name}, so it registers under ` +
|
|
132
|
+
`${nameScope} — not ${given}. A foundation's scope is the one in its name.`
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
return nameScope || scope
|
|
136
|
+
}
|
|
137
|
+
|
|
111
138
|
// Lower a `{ '@/<name>': normalizedSchema }` map into the sorted
|
|
112
139
|
// `@uniweb/data-schema` entity list, resolving each own `@/x` to the publish
|
|
113
140
|
// scope. Shared by the foundation publish (buildRegistryPackage) and the
|
package/src/uwx/self-scope.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// `@/x` — a Model ref in the site's own
|
|
1
|
+
// `@/x` — a Model ref in the site's foundation's own scope — and its qualified form.
|
|
2
2
|
//
|
|
3
3
|
// `@/member` names a data schema in the foundation's own `schemas/`. It is
|
|
4
4
|
// AUTHORING shorthand: a backend resolves Models by name and never mints one, so a
|
|
5
5
|
// ref that leaves the CLI must be the name the Model was stored under,
|
|
6
|
-
// `@<
|
|
6
|
+
// `@<scope>/member`. Everything that reads a ref off the wire matches it exactly —
|
|
7
7
|
// the entity store at restore, and a records service answering a hosted page's
|
|
8
8
|
// query.
|
|
9
9
|
//
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// - a query's `schema` `site.js::queriesNested` (the `queries` Section)
|
|
14
14
|
// - the author's spelling, on pull `records-project.js` (placement and declarations)
|
|
15
15
|
//
|
|
16
|
-
// ⛔ They must agree on one
|
|
16
|
+
// ⛔ They must agree on one scope. When the query path shipped `@/member` verbatim
|
|
17
17
|
// while the records beside it were qualified, the query named a Model its own
|
|
18
18
|
// records were not stored under: a hosted page's question named a Model that does
|
|
19
19
|
// not exist, the records service refused that key, and the section rendered
|
|
@@ -21,39 +21,52 @@
|
|
|
21
21
|
// `block.dataError`, the console clean. Nothing on either side was malformed; the
|
|
22
22
|
// two paths disagreed.
|
|
23
23
|
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
// (
|
|
24
|
+
// ⭐ THE SCOPE IS THE FOUNDATION'S — the one in its name (`@acme/marketing`), which is
|
|
25
|
+
// the scope `register` stored the foundation's own data schemas under
|
|
26
|
+
// (`registry-package.js`). ⛔ Until 2026-09-22 every path above qualified with the org
|
|
27
|
+
// that owns the SITE instead — a stand-in that holds only when one org owns both, and
|
|
28
|
+
// a foundation may be registered under any org its author belongs to [Diego]. A site
|
|
29
|
+
// owned by `@client` on a foundation registered as `@acme/fnd` shipped its records as
|
|
30
|
+
// `@client/member`, a Model `register` never stored; a personal site shipped
|
|
31
|
+
// `@/member` unresolved (both measured). `siteSelfScope` is where the scope comes from.
|
|
32
|
+
|
|
33
|
+
import { readFileSync } from 'node:fs'
|
|
34
|
+
import { join, resolve } from 'node:path'
|
|
35
|
+
import yaml from 'js-yaml'
|
|
36
|
+
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
37
|
+
import { detectFoundationType, parseCatalogRef } from '../site/foundation-ref.js'
|
|
38
|
+
import { readFoundationName } from '../schema.js'
|
|
39
|
+
import { splitFoundationName } from '../foundation-name.js'
|
|
27
40
|
|
|
28
41
|
/**
|
|
29
|
-
*
|
|
30
|
-
*
|
|
42
|
+
* A scope as it may be written — `@acme`, `acme`, or `@acme/…` — reduced to the bare
|
|
43
|
+
* handle, or `''`.
|
|
31
44
|
*
|
|
32
|
-
* @param {unknown}
|
|
45
|
+
* @param {unknown} scope
|
|
33
46
|
* @returns {string}
|
|
34
47
|
*/
|
|
35
|
-
export function bareOrg(
|
|
36
|
-
return typeof
|
|
48
|
+
export function bareOrg(scope) {
|
|
49
|
+
return typeof scope === 'string' ? scope.replace(/^@/, '').replace(/\/.*$/, '') : ''
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
/**
|
|
40
|
-
* `@/x` → `@<
|
|
41
|
-
* does `@/x` when no
|
|
53
|
+
* `@/x` → `@<scope>/x`. Any other ref (`@std/x`, `@acme/x`) passes through, and so
|
|
54
|
+
* does `@/x` when no scope is known — callers that ship an unresolved alias say so
|
|
42
55
|
* themselves (`buildRecordEntities` warns per query).
|
|
43
56
|
*
|
|
44
57
|
* @param {unknown} ref
|
|
45
|
-
* @param {unknown}
|
|
58
|
+
* @param {unknown} scope - the site's foundation's scope (`siteSelfScope`)
|
|
46
59
|
* @returns {unknown}
|
|
47
60
|
*/
|
|
48
|
-
export function resolveSelfScope(ref,
|
|
49
|
-
const handle = bareOrg(
|
|
61
|
+
export function resolveSelfScope(ref, scope) {
|
|
62
|
+
const handle = bareOrg(scope)
|
|
50
63
|
return typeof ref === 'string' && ref.startsWith('@/') && handle
|
|
51
64
|
? `@${handle}/${ref.slice(2)}`
|
|
52
65
|
: ref
|
|
53
66
|
}
|
|
54
67
|
|
|
55
68
|
/**
|
|
56
|
-
* The inverse, for a pull: `@<
|
|
69
|
+
* The inverse, for a pull: `@<scope>/x` → `@/x` for the site's foundation's scope.
|
|
57
70
|
*
|
|
58
71
|
* ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
|
|
59
72
|
* on both ends. A record authored under `records/article/` comes back as
|
|
@@ -63,16 +76,90 @@ export function resolveSelfScope(ref, org) {
|
|
|
63
76
|
* relied on the query-name default comes back with an explicit schema it never
|
|
64
77
|
* had.
|
|
65
78
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* genuinely is that org's, and `@/` would be a lie.
|
|
79
|
+
* A model scoped to ANY OTHER scope is left alone: it genuinely is that org's, and
|
|
80
|
+
* `@/` would be a lie.
|
|
69
81
|
*
|
|
70
82
|
* @param {unknown} ref
|
|
71
|
-
* @param {unknown}
|
|
83
|
+
* @param {unknown} scope - the scope the pulled records were qualified with
|
|
72
84
|
* @returns {unknown}
|
|
73
85
|
*/
|
|
74
|
-
export function unresolveSelfScope(ref,
|
|
75
|
-
const handle = bareOrg(
|
|
86
|
+
export function unresolveSelfScope(ref, scope) {
|
|
87
|
+
const handle = bareOrg(scope)
|
|
76
88
|
if (typeof ref !== 'string' || !handle) return ref
|
|
77
89
|
return ref.startsWith(`@${handle}/`) ? `@/${ref.slice(handle.length + 2)}` : ref
|
|
78
90
|
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The scope a site's `@/x` refs resolve into: its foundation's, `@org` — or null
|
|
94
|
+
* when it has none yet.
|
|
95
|
+
*
|
|
96
|
+
* - `foundation` given (a pull passes the pinned ref the pushed site carries) — a
|
|
97
|
+
* catalog ref's scope, or a local foundation's, resolved from that value.
|
|
98
|
+
* - else `foundationDir` — that local foundation's.
|
|
99
|
+
* - else `site.yml::foundation`, the same way.
|
|
100
|
+
*
|
|
101
|
+
* A local foundation's scope is the one in its name, read by the one rule
|
|
102
|
+
* (`readFoundationName`, the reader `register` and `push` use), so what a push pins
|
|
103
|
+
* and what it qualifies with cannot differ. A bare name has no scope until `register`
|
|
104
|
+
* gives it one; a URL foundation has none.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ A foundation whose name cannot be read (a leftover `uniweb.scope`, a `main.js`
|
|
107
|
+
* that fails to load) answers null here. `register` — which a push and a publish run
|
|
108
|
+
* first — says why; this only qualifies.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} siteRoot
|
|
111
|
+
* @param {{ foundation?: unknown, foundationDir?: string }} [opts]
|
|
112
|
+
* @returns {Promise<string|null>}
|
|
113
|
+
*/
|
|
114
|
+
export async function siteSelfScope(siteRoot, { foundation, foundationDir } = {}) {
|
|
115
|
+
if (foundation === undefined && foundationDir) return localFoundationScope(resolve(foundationDir))
|
|
116
|
+
const declared = foundation !== undefined ? foundation : readDeclaredFoundation(siteRoot)
|
|
117
|
+
const value = declared && typeof declared === 'object' ? declared.name : declared
|
|
118
|
+
if (typeof value !== 'string' || !value) return null
|
|
119
|
+
const ref = parseCatalogRef(value)
|
|
120
|
+
if (ref) return ref.scope
|
|
121
|
+
let info = null
|
|
122
|
+
try {
|
|
123
|
+
info = detectFoundationType(value, siteRoot)
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
return info?.type === 'local' && info.path ? localFoundationScope(info.path) : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function localFoundationScope(dir) {
|
|
131
|
+
try {
|
|
132
|
+
const { name } = await readFoundationName(dir)
|
|
133
|
+
return splitFoundationName(name).scope
|
|
134
|
+
} catch {
|
|
135
|
+
return null
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function readDeclaredFoundation(siteRoot) {
|
|
140
|
+
try {
|
|
141
|
+
const siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
|
|
142
|
+
return siteYml.foundation ?? null
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Refuse the retired `org` option.
|
|
150
|
+
*
|
|
151
|
+
* ⛔ It carried the org that owns the SITE, and qualified a site's `@/x` refs with it
|
|
152
|
+
* (until 2026-09-22). Ignoring it would be silent, so a caller still passing it is told
|
|
153
|
+
* what replaced it: the foundation's scope, derived — or `scope`, to state it.
|
|
154
|
+
*
|
|
155
|
+
* @param {object|undefined} opts
|
|
156
|
+
* @param {string} where - the function, for the message
|
|
157
|
+
*/
|
|
158
|
+
export function refuseOrgOption(opts, where) {
|
|
159
|
+
if (opts && opts.org !== undefined) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
`${where}: \`org\` is no longer read — a site's \`@/x\` refs resolve into its ` +
|
|
162
|
+
`foundation's scope (the one in the foundation's name). Pass \`scope\` to state it.`
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
}
|
package/src/uwx/site-project.js
CHANGED
|
@@ -868,9 +868,12 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
|
|
|
868
868
|
* files that have no corresponding incoming item (git-pull-like). Off by
|
|
869
869
|
* default; `uniweb pull` opts in. Guarded against wiping a level on an
|
|
870
870
|
* empty set.
|
|
871
|
+
* @param {string|null} [params.scope] - the scope the push qualified a query's
|
|
872
|
+
* `@/x` schema with, so it is written back as `@/x` — see
|
|
873
|
+
* `declarationsToQueriesYml`, whose default it overrides.
|
|
871
874
|
* @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
|
|
872
875
|
*/
|
|
873
|
-
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false }) {
|
|
876
|
+
export function siteContentDocumentToProject({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false, keepAuthoredFoundation = false, scope }) {
|
|
874
877
|
const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
|
|
875
878
|
|
|
876
879
|
// Collects target-locale translations of localized scalars as they're projected;
|
|
@@ -893,7 +896,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
|
|
|
893
896
|
)
|
|
894
897
|
|
|
895
898
|
report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
|
|
896
|
-
report.queries = declarationsToQueriesYml({ document, siteRoot,
|
|
899
|
+
report.queries = declarationsToQueriesYml({ document, siteRoot, scope })
|
|
897
900
|
|
|
898
901
|
// The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
|
|
899
902
|
// rename detection, build a fresh one as we project, then persist it. Items not
|
package/src/uwx/site.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Map a file site project to one `@uniweb/site-content` entity, as the
|
|
2
|
-
// section-keyed
|
|
2
|
+
// section-keyed `$`-document (docs/reference/entity-content.md), then to a
|
|
3
3
|
// `subtype: entity` .uwx on the SYNC lane.
|
|
4
4
|
//
|
|
5
5
|
// SOURCE LAYER: the content-collector's return is flattened and lossy for
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
// processMarkdownFile, ordering, mode detection) so those semantics stay
|
|
9
9
|
// identical to a normal build — only the directory/mode/order *walk* is ours.
|
|
10
10
|
//
|
|
11
|
-
// The document mirrors the @uniweb/site-content Model: `info` (brief) · `
|
|
12
|
-
// (self-nesting; each page carries its `page_sections` as an inline
|
|
13
|
-
// `layout_sections` · `extensions` · `queries
|
|
11
|
+
// The document mirrors the @uniweb/site-content Model: `info` (brief) · `settings`
|
|
12
|
+
// · `pages` (self-nesting; each page carries its `page_sections` as an inline
|
|
13
|
+
// field) · `layout_sections` · `extensions` · `queries` · `services` · `secrets`.
|
|
14
|
+
// `settings` is emitted only when non-empty; `services` and `secrets` only when the
|
|
15
|
+
// backend being pushed to has that key in `sync.json` (absent is not empty — see
|
|
16
|
+
// below). `emit-surface.json` states which `site.yml` key lands in `info` and
|
|
17
|
+
// `settings`. `info.foundation`
|
|
14
18
|
// carries the verbatim `site.yml::foundation` string (the round-trip source of
|
|
15
19
|
// truth).
|
|
16
20
|
//
|
|
@@ -73,7 +77,7 @@ import { loadFreeformTranslation } from '../i18n/freeform.js'
|
|
|
73
77
|
import { updateBackendState, readBackendState } from './sync-store.js'
|
|
74
78
|
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
75
79
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
76
|
-
import { resolveSelfScope } from './self-scope.js'
|
|
80
|
+
import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
77
81
|
import { siteContentDirs } from './site-dirs.js'
|
|
78
82
|
|
|
79
83
|
const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
|
|
@@ -915,7 +919,7 @@ function externalSource(d) {
|
|
|
915
919
|
return source
|
|
916
920
|
}
|
|
917
921
|
|
|
918
|
-
function queriesNested(declarations, uuids = null,
|
|
922
|
+
function queriesNested(declarations, uuids = null, scope = null) {
|
|
919
923
|
const out = []
|
|
920
924
|
for (const [name, d] of Object.entries(declarations)) {
|
|
921
925
|
refuseUnder(d.where, `queries.${name}`)
|
|
@@ -926,13 +930,13 @@ function queriesNested(declarations, uuids = null, org = null) {
|
|
|
926
930
|
// the runtime fetches it from (never the records service).
|
|
927
931
|
const source = d.path ? { path: d.path } : d.url !== undefined ? externalSource(d) : d.source
|
|
928
932
|
setIf(data, 'source', source)
|
|
929
|
-
// ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME
|
|
930
|
-
// (`records.js::buildRecordEntities`)
|
|
931
|
-
// this name against the Models its records were
|
|
932
|
-
// `@/member` beside records stored as `@
|
|
933
|
-
// resolves no Model and the page that binds it renders
|
|
934
|
-
// author's `@/` back (`records-project.js::declarationsToQueriesYml`).
|
|
935
|
-
setIf(data, 'schema', resolveSelfScope(d.schema,
|
|
933
|
+
// ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME SCOPE AS THE RECORDS' `$model`
|
|
934
|
+
// (`records.js::buildRecordEntities`) — the site's foundation's. A consumer
|
|
935
|
+
// answers a query by matching this name against the Models its records were
|
|
936
|
+
// stored under, so a verbatim `@/member` beside records stored as `@acme/member`
|
|
937
|
+
// names nothing: the query resolves no Model and the page that binds it renders
|
|
938
|
+
// empty. The pull puts the author's `@/` back (`records-project.js::declarationsToQueriesYml`).
|
|
939
|
+
setIf(data, 'schema', resolveSelfScope(d.schema, scope))
|
|
936
940
|
setIf(data, 'sort', d.sort)
|
|
937
941
|
// Legacy `filter:` is not synced — it is translated to `where` upstream
|
|
938
942
|
// (the canonical predicate). No legacy fields on the wire.
|
|
@@ -1009,16 +1013,19 @@ function queriesNested(declarations, uuids = null, org = null) {
|
|
|
1009
1013
|
// ⛔ ABSENT IS NOT EMPTY, and the difference is destructive. The Section is
|
|
1010
1014
|
// REPLACED by what we send, so `[]` means "drop every stored config row" while a
|
|
1011
1015
|
// missing key means "I am not telling you about this". A project that has never
|
|
1012
|
-
// pulled has no
|
|
1013
|
-
// wipe a service the operator configured in the app. So: emit the
|
|
1014
|
-
//
|
|
1016
|
+
// pulled has no `services` for that backend, and its ordinary push must not read as
|
|
1017
|
+
// a request to wipe a service the operator configured in the app. So: emit the
|
|
1018
|
+
// Section only when that backend's entry in `sync.json` carries the key. Clearing is
|
|
1019
|
+
// available and explicit — `services: []` there. *(Both said `site.yml::$services`
|
|
1020
|
+
// until the move on 2026-09-20.)*
|
|
1015
1021
|
//
|
|
1016
1022
|
// ⚠️ The push gate is NOT what makes this safe, though it usually catches it: its
|
|
1017
1023
|
// tokens live in a gitignored per-clone cache, so a fresh clone pushes
|
|
1018
1024
|
// unconditionally. Correctness has to sit here.
|
|
1019
1025
|
|
|
1020
1026
|
/**
|
|
1021
|
-
*
|
|
1027
|
+
* A backend's provisioned `services` / `secrets` (its `sync.json` entry) → Section
|
|
1028
|
+
* records, or undefined when the key is absent.
|
|
1022
1029
|
*
|
|
1023
1030
|
* ⭐ PASSTHROUGH, NOT AN ALLOWLIST — the same rule and the same reason as
|
|
1024
1031
|
* `queriesNested` above. The field set belongs to the backend's Model, a service's
|
|
@@ -1027,7 +1034,8 @@ function queriesNested(declarations, uuids = null, org = null) {
|
|
|
1027
1034
|
* would DESTROY whatever is stored under it on every push. Framework can enumerate
|
|
1028
1035
|
* its own vocabulary and cannot enumerate theirs; withhold ours, forward the rest.
|
|
1029
1036
|
*
|
|
1030
|
-
* @param {*} declared - the raw
|
|
1037
|
+
* @param {*} declared - the raw `services` / `secrets` value from that backend's
|
|
1038
|
+
* `sync.json` entry
|
|
1031
1039
|
* @param {(entry: object) => string|null} identify - the record's stable `$id`
|
|
1032
1040
|
* @param {string} label - the key name, for the one warning below
|
|
1033
1041
|
* @returns {object[]|undefined}
|
|
@@ -1229,13 +1237,16 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1229
1237
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
|
|
1230
1238
|
* the site's effective default locale (`defaultLanguage || languages[0] ||
|
|
1231
1239
|
* 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
|
|
1232
|
-
* @param {string} [opts.
|
|
1233
|
-
*
|
|
1234
|
-
* records are emitted with
|
|
1240
|
+
* @param {string|null} [opts.scope] - the scope a query's foundation-relative `schema`
|
|
1241
|
+
* resolves into (`@/x` → `@scope/x`): the site's foundation's. Defaults to
|
|
1242
|
+
* `siteSelfScope`; pass the scope the records are emitted with. With none
|
|
1243
|
+
* known, `@/x` ships as written.
|
|
1235
1244
|
* @returns {Promise<object>} the section-keyed `$`-document:
|
|
1236
|
-
* `{ $uuid?, $id, $model, info, pages, layout_sections, extensions,
|
|
1245
|
+
* `{ $uuid?, $id, $model, info, settings?, pages, layout_sections, extensions,
|
|
1246
|
+
* queries, services?, secrets? }`
|
|
1237
1247
|
*/
|
|
1238
1248
|
export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
1249
|
+
refuseOrgOption(opts, 'uwx/site')
|
|
1239
1250
|
const siteYml = await readYamlFile(join(siteRoot, 'site.yml'))
|
|
1240
1251
|
const sourceLocale = opts.sourceLocale || resolveDefaultLocale(siteYml)
|
|
1241
1252
|
if (!siteYml.name) {
|
|
@@ -1533,7 +1544,8 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1533
1544
|
//
|
|
1534
1545
|
// ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
|
|
1535
1546
|
// consumer sees, leave the identifier alone.
|
|
1536
|
-
|
|
1547
|
+
const scope = opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot)
|
|
1548
|
+
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, scope)
|
|
1537
1549
|
// Emitted ONLY when the file declares the key — see the header above
|
|
1538
1550
|
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1539
1551
|
// requests and one of them is destructive.
|
|
@@ -1618,53 +1630,13 @@ export function writeSiteEntityUuid(siteRoot, backend, uuid) {
|
|
|
1618
1630
|
return updateBackendState(siteRoot, backend, { site: { uuid } })
|
|
1619
1631
|
}
|
|
1620
1632
|
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
* this site's storage charged to?"*, which `$uuid` alone cannot answer and which
|
|
1629
|
-
* otherwise costs a backend round-trip (or is simply unknowable from the repo).
|
|
1630
|
-
*
|
|
1631
|
-
* Stored as the BARE handle, never `@handle` — the canonical form everywhere else
|
|
1632
|
-
* (`deriveScope` returns it, `createOrg` echoes it as `org.handle`,
|
|
1633
|
-
* `validateHandle` validates it); the `@` is display sugar the reader re-adds. In
|
|
1634
|
-
* `site.yml` it was also the only form that parsed: `@` is a reserved YAML
|
|
1635
|
-
* indicator, so `$org: @acme` is an error.
|
|
1636
|
-
*
|
|
1637
|
-
* ⛔ NOT `deploy.yml`, though that file already holds the bound `backend` and the
|
|
1638
|
-
* two look like the same class of fact. Four reasons, and the first is the one that
|
|
1639
|
-
* settles it:
|
|
1640
|
-
*
|
|
1641
|
-
* 1. CARDINALITY. `deploy.yml` is multi-TARGET and `backend` sits *under* a target,
|
|
1642
|
-
* so its shape says "this may vary per target." An org may not: one site has one
|
|
1643
|
-
* owning org, fixed at create and preserved on replace. The org is a property of
|
|
1644
|
-
* the site's uuid, and sits beside it in the same backend's section — filing it
|
|
1645
|
-
* under a target would encode a freedom that does not exist.
|
|
1646
|
-
* 2. WHO WRITES. Three paths mint a site (`ensureSiteExists`, the media-less push's
|
|
1647
|
-
* content-lane create, and `clone` seeding an existing one) and **none of them
|
|
1648
|
-
* write `deploy.yml`** — only `deploy` and `publish` call `recordLastDeploy`. The
|
|
1649
|
-
* record would exist or not depending on which verb the developer reached for.
|
|
1650
|
-
* 3. SUPPRESSIBLE. `recordLastDeploy` is a no-op under `autoSave: off` / `--no-save`.
|
|
1651
|
-
* Turning off deploy *receipts* would silently drop an *ownership* record.
|
|
1652
|
-
* 4. SEMANTICS. `deploy.yml` describes the act of shipping (`lastDeploy` is a
|
|
1653
|
-
* receipt) and is optional entirely. But `push` creates a site and never
|
|
1654
|
-
* publishes — a site can exist, be owned, and accrue storage charges without
|
|
1655
|
-
* ever being deployed. Ownership does not belong in a record of a deploy that
|
|
1656
|
-
* may not have happened.
|
|
1657
|
-
*
|
|
1658
|
-
* `backend` answers *where this ships*; `site.org` answers *whose this is*.
|
|
1659
|
-
*
|
|
1660
|
-
* @param {string} siteRoot
|
|
1661
|
-
* @param {string} backend - the origin of the backend the site was created on
|
|
1662
|
-
* @param {string} handle - the bare org handle (no leading `@`)
|
|
1663
|
-
* @returns {boolean} true if sync.json changed
|
|
1664
|
-
*/
|
|
1665
|
-
export function writeSiteOrg(siteRoot, backend, handle) {
|
|
1666
|
-
return updateBackendState(siteRoot, backend, { site: { org: handle } })
|
|
1667
|
-
}
|
|
1633
|
+
// ⛔ `writeSiteOrg` is GONE (2026-09-23). The workspace a project's requests name is two
|
|
1634
|
+
// keys of `site` now — `org` (a handle, bare) and `unit` (the uuid of a workspace with no
|
|
1635
|
+
// handle), at most one set — and every site request names it, so it is no longer a note
|
|
1636
|
+
// written once at the create. A writer of one key would leave the other stale; write
|
|
1637
|
+
// both through `updateBackendState`. Why it lives here rather than in `deploy.yml`: a
|
|
1638
|
+
// site has one workspace per backend, fixed by the create, while `deploy.yml` is per
|
|
1639
|
+
// target and records the act of shipping, which `push` never does.
|
|
1668
1640
|
|
|
1669
1641
|
// ⛔ `writeSiteBackend` and `site.yml::$backend` are GONE (2026-09-20). The scope is
|
|
1670
1642
|
// no longer a value recorded beside the uuid — it is the KEY the identity is stored
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -23,6 +23,7 @@ import { buildRecordEntities, entityContentHash } from './records.js'
|
|
|
23
23
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
24
24
|
import { buildFolderEntity } from './folder.js'
|
|
25
25
|
import { siteProjectToDocument } from './site.js'
|
|
26
|
+
import { siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
26
27
|
import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
|
|
27
28
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
28
29
|
import { isLocalAssetPath } from '../site/assets.js'
|
|
@@ -205,6 +206,9 @@ function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
|
205
206
|
* @param {string} siteRoot - directory containing site.yml
|
|
206
207
|
* @param {object} [opts]
|
|
207
208
|
* @param {string} [opts.foundationDir] - local foundation root (collection Models)
|
|
209
|
+
* @param {string|null} [opts.scope] - the scope the site's `@/x` refs resolve
|
|
210
|
+
* into. Defaults to its foundation's (`siteSelfScope`) — the one in the
|
|
211
|
+
* foundation's name. `org` is refused: it carried the site owner's.
|
|
208
212
|
* @param {Function} [opts.resolveModel] - async non-local Model resolver
|
|
209
213
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale
|
|
210
214
|
* @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
|
|
@@ -271,6 +275,13 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
271
275
|
const stamp = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {})
|
|
272
276
|
const exporter = opts.exporter
|
|
273
277
|
const exportedAt = opts.exportedAt
|
|
278
|
+
refuseOrgOption(opts, 'emitSyncPackages')
|
|
279
|
+
// ⭐ ONE SCOPE FOR THE WHOLE EMIT — the site's foundation's, the one `register`
|
|
280
|
+
// stored its Models under — so a record's `$model` and a query's `schema` are
|
|
281
|
+
// qualified alike. Read once here and handed to both. ⛔ It was the org that owns
|
|
282
|
+
// the SITE until 2026-09-22 (`self-scope.js`).
|
|
283
|
+
const scope =
|
|
284
|
+
opts.scope !== undefined ? opts.scope : await siteSelfScope(siteRoot, { foundationDir: opts.foundationDir })
|
|
274
285
|
|
|
275
286
|
const col = await buildRecordEntities(siteRoot, {
|
|
276
287
|
// Which backend's record uuids go on the wire (sync.json) — see records.js.
|
|
@@ -278,10 +289,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
278
289
|
...(opts.foundationDir ? { foundationDir: opts.foundationDir } : {}),
|
|
279
290
|
...(opts.resolveModel ? { resolveModel: opts.resolveModel } : {}),
|
|
280
291
|
...(sourceLocale ? { sourceLocale } : {}),
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
|
|
292
|
+
// Resolves a foundation-relative `@/x` model ref into `@scope/x` before it ships.
|
|
293
|
+
// Null for a foundation with no scope yet, which is why buildRecordEntities
|
|
294
|
+
// warns rather than throws.
|
|
295
|
+
scope,
|
|
285
296
|
})
|
|
286
297
|
const warnings = [...col.warnings, ...(col.folder?.warnings ?? [])]
|
|
287
298
|
|
|
@@ -325,10 +336,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
325
336
|
// Whose $uuid belongs on this wire document — see siteProjectToDocument.
|
|
326
337
|
...(opts.backend ? { backend: opts.backend } : {}),
|
|
327
338
|
...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
|
|
328
|
-
// ⛔ THE SAME
|
|
339
|
+
// ⛔ THE SAME SCOPE `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
|
|
329
340
|
// must name the Model its records are stored under, and both are qualified
|
|
330
|
-
// from one `@/x` by one rule (`./self-scope.js`) — so they take one
|
|
331
|
-
|
|
341
|
+
// from one `@/x` by one rule (`./self-scope.js`) — so they take one scope.
|
|
342
|
+
scope,
|
|
332
343
|
// Withhold the `$services`/`$secrets` Sections when the caller has
|
|
333
344
|
// determined the file is not asking for anything new by them. Passed
|
|
334
345
|
// through rather than decided here: the last-agreed state is project
|