@uniweb/build 0.57.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 +9 -9
- package/src/foundation-name.js +89 -0
- package/src/i18n/records.js +1 -1
- package/src/index.js +9 -0
- package/src/prerender.js +1 -1
- package/src/runtime-schema.js +4 -2
- package/src/schema.js +95 -10
- package/src/site/content-collector.js +1 -1
- package/src/site/entity-pool.js +58 -10
- package/src/site/foundation-ref.js +15 -2
- package/src/site/plugin.js +9 -7
- package/src/site/queries-config.js +3 -3
- package/src/site/query-processor.js +8 -7
- package/src/site/records-config.js +73 -32
- package/src/utils/numeric-prefix.js +1 -1
- package/src/uwx/backfill.js +22 -2
- package/src/uwx/entity-source.js +1 -1
- package/src/uwx/folder.js +3 -3
- package/src/uwx/index.js +7 -4
- package/src/uwx/project-writer.js +13 -11
- package/src/uwx/records-project.js +56 -61
- package/src/uwx/records.js +55 -49
- package/src/uwx/registry-package.js +40 -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 +20 -9
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Records projection — write a pulled folder and its records back to the site's
|
|
2
|
-
// `records/**` files and `records.yml`. The
|
|
3
|
-
// + `folder.js`): the producer reads the
|
|
4
|
-
// `@uniweb/folder` entity + one section-keyed
|
|
5
|
-
// those documents back and renders them to files.
|
|
2
|
+
// `records/**` files and the folder's organization, `records/folder.yml`. The
|
|
3
|
+
// inverse of the producer (`records.js` + `folder.js`): the producer reads the
|
|
4
|
+
// records directory and emits the `@uniweb/folder` entity + one section-keyed
|
|
5
|
+
// `$`-document per record; this takes those documents back and renders them to files.
|
|
6
6
|
//
|
|
7
7
|
// Identity & placement:
|
|
8
8
|
// - a record's slug comes from the FOLDER document — each ref leaf is
|
|
@@ -38,9 +38,10 @@ import { parseFrontmatter } from './entity-source.js'
|
|
|
38
38
|
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
39
39
|
import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
|
|
40
40
|
import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
|
|
41
|
-
import {
|
|
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
|
}
|
|
@@ -370,18 +362,19 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
|
|
|
370
362
|
}
|
|
371
363
|
|
|
372
364
|
/**
|
|
373
|
-
* Project a pulled `@uniweb/folder` document back to `
|
|
365
|
+
* Project a pulled `@uniweb/folder` document back to `folder.yml` in the records
|
|
366
|
+
* directory (`records/folder.yml`, or under `site.yml::paths.records`).
|
|
374
367
|
*
|
|
375
368
|
* ⭐ THE FOLDER ROUND-TRIPS TRIVIALLY, and that is by design rather than luck:
|
|
376
|
-
* `
|
|
369
|
+
* `folder.yml` holds concrete paths on both sides, so there is nothing to invert.
|
|
377
370
|
* The old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
|
|
378
371
|
* collections — and inverting a macro is not possible in general. Taking queries
|
|
379
372
|
* out of the folder is what dissolved that.
|
|
380
373
|
*
|
|
381
374
|
* ⭐ ONLY THE SUB-FOLDERS ARE WRITTEN (ruled 2026-09-21 [Diego]). A record at the
|
|
382
375
|
* top of the folder needs no line — being in the records directory is what puts it
|
|
383
|
-
* there — so `
|
|
384
|
-
* a folder with no branches is a site with no `
|
|
376
|
+
* there — so `folder.yml` carries the branches and the records placed in them, and
|
|
377
|
+
* a folder with no branches is a site with no `folder.yml`: a local one is REMOVED
|
|
385
378
|
* (it could only describe sub-folders the backend's folder no longer has). No state
|
|
386
379
|
* of the file removes a record, so this cannot empty anything.
|
|
387
380
|
*
|
|
@@ -395,11 +388,13 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
|
|
|
395
388
|
* under `records/` of the file just written for it. Supplied by
|
|
396
389
|
* `recordsToProject`, which is the only thing that knows the extension
|
|
397
390
|
* each record landed with.
|
|
398
|
-
* @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[] }}
|
|
391
|
+
* @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[], file: string }}
|
|
392
|
+
* `file` is where the organization lives, as the author would write it
|
|
399
393
|
*/
|
|
400
|
-
export function
|
|
394
|
+
export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
|
|
401
395
|
const warnings = []
|
|
402
|
-
|
|
396
|
+
const { abs, rel: file } = folderYmlPath(siteRoot)
|
|
397
|
+
if (!folderDoc) return { status: 'skipped', entries: [], warnings, file }
|
|
403
398
|
let unplaceable = false
|
|
404
399
|
|
|
405
400
|
const walk = (nodes, inBranch) => {
|
|
@@ -410,7 +405,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
410
405
|
const entry = { folder: node.name }
|
|
411
406
|
// Only a BRANCH takes a label. A record carries its own title; the folder
|
|
412
407
|
// does not caption its rows. On the wire the label is a localized map;
|
|
413
|
-
//
|
|
408
|
+
// folder.yml carries the source-locale string (a bare string passes).
|
|
414
409
|
if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
|
|
415
410
|
entry.records = walk(node.$children, true)
|
|
416
411
|
out.push(entry)
|
|
@@ -435,7 +430,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
435
430
|
// means the folder and the directory disagree, and writing the file without
|
|
436
431
|
// it would move that record to the top of the folder on the next push.
|
|
437
432
|
warnings.push(
|
|
438
|
-
|
|
433
|
+
`${file}: the folder places a record ("${node.name ?? '?'}") that was not ` +
|
|
439
434
|
`written locally — the file was left unchanged rather than dropping it.`
|
|
440
435
|
)
|
|
441
436
|
unplaceable = true
|
|
@@ -447,14 +442,13 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
447
442
|
}
|
|
448
443
|
|
|
449
444
|
const entries = walk(folderDoc.contents, false)
|
|
450
|
-
if (unplaceable) return { status: 'skipped', entries: [], warnings }
|
|
445
|
+
if (unplaceable) return { status: 'skipped', entries: [], warnings, file }
|
|
451
446
|
if (entries.length === 0) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
return { status: 'removed', entries, warnings }
|
|
447
|
+
if (!existsSync(abs)) return { status: 'unchanged', entries, warnings, file }
|
|
448
|
+
unlinkSync(abs)
|
|
449
|
+
return { status: 'removed', entries, warnings, file }
|
|
456
450
|
}
|
|
457
|
-
return { status: writeRecordsConfig(siteRoot, entries), entries, warnings }
|
|
451
|
+
return { status: writeRecordsConfig(siteRoot, entries), entries, warnings, file }
|
|
458
452
|
}
|
|
459
453
|
|
|
460
454
|
/**
|
|
@@ -467,20 +461,21 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
467
461
|
* @param {object} params.opts
|
|
468
462
|
* @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
|
|
469
463
|
* - resolve a Model's data-schema declaration by name (`$model`).
|
|
470
|
-
* @param {string} [params.opts.
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
*
|
|
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
|
|
475
469
|
* @param {string} [params.opts.sourceLocale]
|
|
476
470
|
* @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
|
|
477
471
|
*/
|
|
478
472
|
export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
|
|
479
473
|
const { resolveDeclaration, sourceLocale = 'en' } = opts
|
|
480
|
-
|
|
481
|
-
//
|
|
482
|
-
// `
|
|
483
|
-
|
|
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
|
|
484
479
|
if (typeof resolveDeclaration !== 'function') {
|
|
485
480
|
throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
|
|
486
481
|
}
|
|
@@ -506,7 +501,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
506
501
|
// own id → their uuid, for every record this pull wrote. Recorded at the end.
|
|
507
502
|
const learned = {}
|
|
508
503
|
// uuid → the path under `records/` the record landed at. Only this loop knows
|
|
509
|
-
// the extension each one got, so `
|
|
504
|
+
// the extension each one got, so `folder.yml` is written from it rather than
|
|
510
505
|
// re-derived (a second rule could pick a different extension and the folder
|
|
511
506
|
// would name a file that is not there).
|
|
512
507
|
const poolPathByUuid = new Map()
|
|
@@ -523,7 +518,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
523
518
|
continue
|
|
524
519
|
}
|
|
525
520
|
|
|
526
|
-
const poolDir = recordDirFor(recordsRoot, document.$model,
|
|
521
|
+
const poolDir = recordDirFor(recordsRoot, document.$model, selfScope)
|
|
527
522
|
if (!poolDir) {
|
|
528
523
|
skipped.push({
|
|
529
524
|
uuid: document.$uuid,
|
|
@@ -571,20 +566,20 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
571
566
|
else if (isNew) placed.push(filePath)
|
|
572
567
|
else updated.push(filePath)
|
|
573
568
|
// Keyed by THEIR uuid: the folder document references records in the backend's
|
|
574
|
-
// terms, so that is what `
|
|
569
|
+
// terms, so that is what `folderToFolderYml` will look them up by.
|
|
575
570
|
if (theirs) {
|
|
576
571
|
poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
|
|
577
572
|
if (own) learned[own] = theirs
|
|
578
573
|
}
|
|
579
574
|
}
|
|
580
575
|
|
|
581
|
-
// ⭐ THE FOLDER ITSELF, written back as `
|
|
576
|
+
// ⭐ THE FOLDER ITSELF, written back as `folder.yml`. Steps that only touched the
|
|
582
577
|
// READ path would leave every pull authoring the old shape — the site would build
|
|
583
578
|
// from the new layout and be projected back into the one it replaced.
|
|
584
579
|
//
|
|
585
580
|
// The folder ENTITY still carries no `$uuid` we persist: the backend owns the
|
|
586
581
|
// site's folder, keyed by the site-content uuid.
|
|
587
|
-
const records =
|
|
582
|
+
const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
|
|
588
583
|
warnings.push(...records.warnings)
|
|
589
584
|
|
|
590
585
|
// Flush localized record-field translations to locales/records/{locale}.json,
|
|
@@ -596,5 +591,5 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
596
591
|
updateBackendMap(siteRoot, opts.backend, 'records', learned)
|
|
597
592
|
}
|
|
598
593
|
|
|
599
|
-
return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status }
|
|
594
|
+
return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status, recordsFile: records.file }
|
|
600
595
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -48,7 +48,7 @@ import { join, resolve } from 'node:path'
|
|
|
48
48
|
|
|
49
49
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
50
50
|
import { readEntityFile } from './entity-source.js'
|
|
51
|
-
import { readRecordsConfig, resolveFolder
|
|
51
|
+
import { readRecordsConfig, resolveFolder } from '../site/records-config.js'
|
|
52
52
|
import {
|
|
53
53
|
readEntityPool,
|
|
54
54
|
groupPoolBySchema,
|
|
@@ -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'
|
|
@@ -80,17 +80,22 @@ const SKIP_KEYS = new Set([
|
|
|
80
80
|
'$unit',
|
|
81
81
|
'$meta',
|
|
82
82
|
'$body',
|
|
83
|
-
// The draft flag (`site/record-draft.js`) — framework's, never a Model field.
|
|
83
|
+
// The draft flag (`site/record-draft.js`) — framework's, never a Model field. It
|
|
84
|
+
// travels as the entity's `$disabled` (see the document below).
|
|
84
85
|
'draft',
|
|
85
86
|
])
|
|
86
87
|
|
|
87
88
|
// Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$model`/… — never
|
|
88
89
|
// field data; the sigil-exclusivity invariant guarantees this) at every level,
|
|
89
|
-
// so a back-filled `$uuid` doesn't change the hash.
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
90
|
+
// so a back-filled `$uuid` doesn't change the hash. Two are exceptions, because
|
|
91
|
+
// they are content rather than identity:
|
|
92
|
+
// - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
|
|
93
|
+
// site-content's nested pages/sections), so it is KEPT and recursed into —
|
|
94
|
+
// otherwise a nesting change would be invisible to "send only changed". Flat
|
|
95
|
+
// records carry no `$children`, so this is a no-op for the collection lane.
|
|
96
|
+
// - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
|
|
97
|
+
// is KEPT so that drafting or un-drafting a record with nothing else changed is
|
|
98
|
+
// still sent; stripped, "send only changed" would never send the toggle.
|
|
94
99
|
function stripSigils(value) {
|
|
95
100
|
if (Array.isArray(value)) return value.map(stripSigils)
|
|
96
101
|
if (value && typeof value === 'object') {
|
|
@@ -123,9 +128,9 @@ function stripSigils(value) {
|
|
|
123
128
|
const out = {}
|
|
124
129
|
for (const [k, v] of Object.entries(value)) {
|
|
125
130
|
if (isFolderRefLeaf && (k === '$ref' || k === 'entry')) continue
|
|
126
|
-
// `$children` (a self-nesting subtree)
|
|
127
|
-
//
|
|
128
|
-
if (k === '$children') {
|
|
131
|
+
// `$children` (a self-nesting subtree) and `$disabled` (a delivery state) are
|
|
132
|
+
// CONTENT, not identity sigils — kept, so a change to either stays visible.
|
|
133
|
+
if (k === '$children' || k === '$disabled') {
|
|
129
134
|
out[k] = stripSigils(v)
|
|
130
135
|
continue
|
|
131
136
|
}
|
|
@@ -338,13 +343,19 @@ export function recordsToEntities({
|
|
|
338
343
|
)
|
|
339
344
|
}
|
|
340
345
|
|
|
341
|
-
// The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`,
|
|
342
|
-
// populated section in declared order (the brief always present as the
|
|
343
|
-
// `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
|
|
346
|
+
// The `$`-document, in canonical key order: `$uuid?`, `$id`, `$model`, `$disabled?`,
|
|
347
|
+
// then each populated section in declared order (the brief always present as the
|
|
348
|
+
// card). `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
|
|
349
|
+
// its side.
|
|
344
350
|
const document = {}
|
|
345
351
|
if (uuid) document.$uuid = uuid
|
|
346
352
|
document.$id = id
|
|
347
353
|
document.$model = declaration.name
|
|
354
|
+
// ⭐ A DRAFT IS SENT AS A DISABLED ENTITY: it stays in the folder and is never
|
|
355
|
+
// publicly delivered. Only `true` travels. An absent key means enabled [Diego,
|
|
356
|
+
// 2026-09-21], so a record whose `draft: true` is removed is sent without the key
|
|
357
|
+
// and is delivered again.
|
|
358
|
+
if (isDraftRecord(record, `${label}/${slug}`)) document.$disabled = true
|
|
348
359
|
document[briefName] = sectionData[briefName] || {}
|
|
349
360
|
for (const [secName] of recordSections) {
|
|
350
361
|
if (secName !== briefName && sectionData[secName]) document[secName] = sectionData[secName]
|
|
@@ -532,7 +543,7 @@ function resolveDeclaration(schema, modelName) {
|
|
|
532
543
|
* PURE assembly (no hashing, no emit), so it composes with other entity sources
|
|
533
544
|
* (e.g. site-content) into one sync package. First sync sends no `$uuid` (the
|
|
534
545
|
* backend mints); re-sync round-trips the back-filled `$uuid`. Throws on an
|
|
535
|
-
* unresolvable EXPLICIT Model, an invalid `records.yml`, or a duplicate
|
|
546
|
+
* unresolvable EXPLICIT Model, an invalid `records/folder.yml`, or a duplicate
|
|
536
547
|
* ($model, $id) within the submission.
|
|
537
548
|
*
|
|
538
549
|
* @param {string} siteRoot - directory containing site.yml
|
|
@@ -543,6 +554,9 @@ function resolveDeclaration(schema, modelName) {
|
|
|
543
554
|
* `@uniweb/data-schema` declaration (or null). The verb wires this to the
|
|
544
555
|
* backend's Model-read route. Without it, the local foundation is required.
|
|
545
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.
|
|
546
560
|
* @returns {Promise<{ entities: object[], index: object[], warnings: string[],
|
|
547
561
|
* schemaless: Array<{name: string, model: string}>, colConfig: object,
|
|
548
562
|
* folder: object, recordsDirExists: boolean }>}
|
|
@@ -556,7 +570,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
556
570
|
// ⭐ EVERY FILE IN `records/` IS A RECORD, AND EVERY RECORD IS PUSHED (ruled
|
|
557
571
|
// 2026-09-21 [Diego]). Placing a file in the directory is what makes it one —
|
|
558
572
|
// the file-side counterpart of placing a ref in the backend's folder — so nothing
|
|
559
|
-
// lists it and nothing leaves it out. `records.yml` only sorts records into
|
|
573
|
+
// lists it and nothing leaves it out. `records/folder.yml` only sorts records into
|
|
560
574
|
// sub-folders. A file named with a leading `_` is not read, so it is not a record.
|
|
561
575
|
//
|
|
562
576
|
// ⛔ A PUSHED RECORD IS NOT LIVE. It sits in the site's folder on the backend and
|
|
@@ -574,12 +588,13 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
574
588
|
// twice and the duplicate check refused the push (measured: "appears in more than
|
|
575
589
|
// one query"), and a record no query read was never pushed. Records are walked
|
|
576
590
|
// once, by the schema their folder declares.
|
|
591
|
+
refuseOrgOption(opts, 'uwx/records')
|
|
577
592
|
const pool = await readEntityPool(siteRoot)
|
|
578
|
-
const recordsCfg = await readRecordsConfig(siteRoot)
|
|
593
|
+
const recordsCfg = await readRecordsConfig(siteRoot, { dir: pool.dir })
|
|
579
594
|
if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
|
|
580
595
|
const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
|
|
581
596
|
if (folder.errors.length) {
|
|
582
|
-
throw new Error(`uwx/records: ${
|
|
597
|
+
throw new Error(`uwx/records: ${recordsCfg.file} is invalid —\n ${folder.errors.join('\n ')}`)
|
|
583
598
|
}
|
|
584
599
|
const warnings = [...pool.errors, ...folder.warnings]
|
|
585
600
|
|
|
@@ -646,19 +661,25 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
646
661
|
//
|
|
647
662
|
// ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
|
|
648
663
|
// (`site.js::queriesNested`): a query's `schema` must name exactly the Model
|
|
649
|
-
// 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 })
|
|
650
670
|
const warnedUnscoped = new Set()
|
|
651
671
|
const modelFor = (schema, where) => {
|
|
652
|
-
const modelName = resolveSelfScope(schema,
|
|
653
|
-
// Unresolvable `@/` — no
|
|
654
|
-
// probe on a never-
|
|
655
|
-
// 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.
|
|
656
676
|
if (modelName === schema && typeof schema === 'string' && schema.startsWith('@/') && !warnedUnscoped.has(schema)) {
|
|
657
677
|
warnedUnscoped.add(schema)
|
|
658
678
|
warnings.push(
|
|
659
|
-
`${where}: \`${schema}\` is foundation-relative and no
|
|
679
|
+
`${where}: \`${schema}\` is foundation-relative and the foundation has no scope yet, ` +
|
|
660
680
|
`so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
|
|
661
|
-
`
|
|
681
|
+
`A foundation's scope is part of its name — \`name: '@org/<name>'\` in its main.js, ` +
|
|
682
|
+
`which \`uniweb register\` writes.`
|
|
662
683
|
)
|
|
663
684
|
}
|
|
664
685
|
return modelName
|
|
@@ -682,7 +703,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
682
703
|
(alternative
|
|
683
704
|
? ` If you meant \`${pool.dir}/${dirs[0]}/\` (${alternative}) organised by ` +
|
|
684
705
|
`\`${dirs[1]}\`, note that a folder inside a schema folder is read as an ` +
|
|
685
|
-
`org scope. Organise records in records.yml, not on disk.`
|
|
706
|
+
`org scope. Organise records in records/folder.yml, not on disk.`
|
|
686
707
|
: '')
|
|
687
708
|
)
|
|
688
709
|
}
|
|
@@ -717,8 +738,6 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
717
738
|
const index = []
|
|
718
739
|
// pool id (the FILE) → the records it produced, so the folder places records.
|
|
719
740
|
const producedBy = new Map()
|
|
720
|
-
// `draft: true` records found on the way — the push is refused if there are any.
|
|
721
|
-
const drafts = []
|
|
722
741
|
// Schemas (as written) whose Model resolved to nothing — soft-skipped.
|
|
723
742
|
const unresolved = new Set()
|
|
724
743
|
// The sync response is keyed per ($model, $id), so the pair must be unique
|
|
@@ -751,10 +770,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
751
770
|
warnings.push(`${pooled.relPath}: a record without a slug was skipped`)
|
|
752
771
|
continue
|
|
753
772
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
773
|
+
// A draft is a record like any other, placed in the folder; the mapper sends it
|
|
774
|
+
// disabled. Checked here too so that a malformed `draft:`, or the retired
|
|
775
|
+
// `published: false`, is refused naming the FILE rather than the record.
|
|
776
|
+
isDraftRecord(r.data, r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath)
|
|
758
777
|
const rec = { ...r.data, slug: r.slug }
|
|
759
778
|
if (r.body !== undefined) rec.$body = r.body
|
|
760
779
|
// ⭐ THE RECORD'S IDENTITY IS ITS POSITION IN THE DIRECTORY — `<dirs>/<slug>`,
|
|
@@ -828,27 +847,14 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
828
847
|
// document → authoring shape (variant A): unwrap localized fields, route
|
|
829
848
|
// the content body field to the md body, drop the brief record `$uuid`.
|
|
830
849
|
declaration,
|
|
850
|
+
// Sent disabled, so the back-fill can check that the backend kept it that way.
|
|
851
|
+
draft: e.document.$disabled === true,
|
|
831
852
|
})
|
|
832
853
|
}
|
|
833
854
|
entities.push(...mappedOut.entities)
|
|
834
855
|
warnings.push(...mappedOut.warnings)
|
|
835
856
|
}
|
|
836
857
|
|
|
837
|
-
// ⛔ A DRAFT IS REFUSED, NOT PUSHED AND NOT SKIPPED. It is a record that stays in the
|
|
838
|
-
// folder but is not delivered — which a backend holds as the entity's `disabled`, a
|
|
839
|
-
// state this producer cannot send yet. Pushed without it, the backend would serve the
|
|
840
|
-
// draft once the site is published; skipped, it would leave the folder — and a site
|
|
841
|
-
// whose records were all drafts would send no folder at all, leaving the backend's
|
|
842
|
-
// live. So the push stops and says which records, until the flag can travel.
|
|
843
|
-
if (drafts.length) {
|
|
844
|
-
throw new Error(
|
|
845
|
-
`uwx/records: a push cannot mark a record as a draft yet, and the backend would serve it ` +
|
|
846
|
-
`once the site is published —\n ${drafts.join('\n ')}\n` +
|
|
847
|
-
' Push them as records by removing `draft: true`, or keep them off the backend by ' +
|
|
848
|
-
'starting their names with `_`.'
|
|
849
|
-
)
|
|
850
|
-
}
|
|
851
|
-
|
|
852
858
|
// Queries whose schema resolved to nothing — not pushed as entities. The composite
|
|
853
859
|
// deploy delivers these statically (the "data ball") instead, so the caller can
|
|
854
860
|
// route them there.
|
|
@@ -867,8 +873,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
867
873
|
? !unresolved.has(schema)
|
|
868
874
|
: Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
|
|
869
875
|
if (!resolved) {
|
|
870
|
-
if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema,
|
|
871
|
-
schemaless.push({ name, model: resolveSelfScope(schema,
|
|
876
|
+
if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, scope), name)
|
|
877
|
+
schemaless.push({ name, model: resolveSelfScope(schema, scope) })
|
|
872
878
|
continue
|
|
873
879
|
}
|
|
874
880
|
if (!hasRecords) {
|
|
@@ -17,15 +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'
|
|
30
|
+
import { checkFoundationName, splitFoundationName } from '../foundation-name.js'
|
|
29
31
|
|
|
30
32
|
const FOUNDATION_SCHEMA = '@uniweb/foundation-schema'
|
|
31
33
|
const DATA_SCHEMA = '@uniweb/data-schema'
|
|
@@ -45,7 +47,9 @@ try {
|
|
|
45
47
|
* @param {Object} params
|
|
46
48
|
* @param {Object} params.schema - parsed `dist/meta/schema.json`.
|
|
47
49
|
* @param {string} [params.foundationDir] - foundation root, for the `i18n/` bundles.
|
|
48
|
-
* @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.
|
|
49
53
|
* @param {Object} [params.exporter] - `{ tool, version, instance }` for the envelope.
|
|
50
54
|
* @param {string} [params.exportedAt] - ISO timestamp (default: now).
|
|
51
55
|
* @param {string} [params.runtime] - the `@uniweb/runtime` version this build
|
|
@@ -69,7 +73,10 @@ export function buildRegistryPackage({ schema, foundationDir, scope, exporter, e
|
|
|
69
73
|
// own `@/x`), each resolved to the concrete publish scope. Shared refs
|
|
70
74
|
// (`@std/x`, `@other/x`) are named in the foundation's data-schemas.refs but
|
|
71
75
|
// their declarations are not bundled — already published.
|
|
72
|
-
const { entities: dataSchemaEntities, scoped, org } = buildDataSchemaEntities(
|
|
76
|
+
const { entities: dataSchemaEntities, scoped, org } = buildDataSchemaEntities(
|
|
77
|
+
dataSchemas,
|
|
78
|
+
registrationScope(self.name, scope)
|
|
79
|
+
)
|
|
73
80
|
|
|
74
81
|
const foundationEntity = {
|
|
75
82
|
model: FOUNDATION_SCHEMA,
|
|
@@ -107,6 +114,27 @@ export function buildSchemaOnlyPackage({ schemas, scope, exporter, exportedAt }
|
|
|
107
114
|
return wrapEntities(entities, exporter, exportedAt)
|
|
108
115
|
}
|
|
109
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
|
+
|
|
110
138
|
// Lower a `{ '@/<name>': normalizedSchema }` map into the sorted
|
|
111
139
|
// `@uniweb/data-schema` entity list, resolving each own `@/x` to the publish
|
|
112
140
|
// scope. Shared by the foundation publish (buildRegistryPackage) and the
|
|
@@ -148,8 +176,14 @@ function wrapEntities(entities, exporter, exportedAt) {
|
|
|
148
176
|
// optional `digest` (sha256:…) is the foundation's content fingerprint; the
|
|
149
177
|
// backend stores it opaque and returns it on the foundation-latest read.
|
|
150
178
|
function buildInfo(self, org, digest, runtime) {
|
|
151
|
-
// Scope a bare foundation name (`
|
|
179
|
+
// Scope a bare foundation name (`marketing` -> `@acme/marketing`); leave an
|
|
180
|
+
// already-scoped name.
|
|
152
181
|
const name = org && !String(self.name).startsWith('@') ? `@${org}/${self.name}` : self.name
|
|
182
|
+
// ⛔ The producer's own guard. `uniweb register` asks for a name before it gets
|
|
183
|
+
// here; this refuses for any other caller, so nothing registers as `@org/src`
|
|
184
|
+
// (`foundation-name.js`).
|
|
185
|
+
const problem = checkFoundationName(name)
|
|
186
|
+
if (problem) throw new Error(`buildRegistryPackage: ${problem}`)
|
|
153
187
|
const info = { name, version: self.version, role: self.role || 'foundation' }
|
|
154
188
|
if (self.description !== undefined) info.description = self.description
|
|
155
189
|
if (digest) info.digest = digest
|