@uniweb/build 0.56.0 → 0.57.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 +7 -7
- package/src/content/index.js +3 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +9 -5
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +4 -3
- package/src/site/entity-pool.js +136 -42
- package/src/site/plugin.js +12 -9
- package/src/site/queries-config.js +15 -5
- package/src/site/query-processor.js +126 -112
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +101 -93
- package/src/uwx/asset-map.js +2 -1
- package/src/uwx/backfill.js +2 -2
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +9 -9
- package/src/uwx/records-project.js +95 -64
- package/src/uwx/records.js +310 -214
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +3 -3
- package/src/uwx/site.js +45 -39
- package/src/uwx/sync-package.js +19 -15
- package/src/validate-data.js +3 -1
package/src/uwx/index.js
CHANGED
|
@@ -116,23 +116,23 @@ export {
|
|
|
116
116
|
} from './records.js'
|
|
117
117
|
export { readEntityFile, parseFrontmatter } from './entity-source.js'
|
|
118
118
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// The pool's grammar — a schema ref to its directory and back. ⛔ Allowlist: an
|
|
123
|
-
// export added to `site/entity-pool.js` does not reach a caller until it is here.
|
|
119
|
+
// Where a site's records live, and the grammar of that directory — a schema ref to
|
|
120
|
+
// its folder and back. ⛔ Allowlist: an export added to `site/entity-pool.js` does
|
|
121
|
+
// not reach a caller until it is here.
|
|
124
122
|
export {
|
|
125
123
|
poolDirsForSchema,
|
|
126
124
|
schemaForPoolDirs,
|
|
127
|
-
|
|
125
|
+
readEntityPool,
|
|
126
|
+
resolveRecordsDir,
|
|
127
|
+
RECORDS_DIR,
|
|
128
128
|
} from '../site/entity-pool.js'
|
|
129
129
|
|
|
130
|
+
// How the folder is organized, so a verb can ask what `records.yml` SAYS without
|
|
131
|
+
// re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
|
|
132
|
+
// `site/records-config.js` does not reach a caller until it is named here.
|
|
130
133
|
export {
|
|
131
134
|
readRecordsConfig,
|
|
132
135
|
RECORDS_YML_RELPATH,
|
|
133
|
-
FOLDER_MISSING,
|
|
134
|
-
FOLDER_EMPTY,
|
|
135
|
-
FOLDER_DECLARED,
|
|
136
136
|
} from '../site/records-config.js'
|
|
137
137
|
export {
|
|
138
138
|
findRecordFile,
|
|
@@ -1,46 +1,44 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Records projection — write a pulled folder and its records back to the site's
|
|
2
|
+
// `records/**` files and `records.yml`. The inverse of the producer (`records.js`
|
|
3
|
+
// + `folder.js`): the producer reads the records directory and emits the
|
|
4
4
|
// `@uniweb/folder` entity + one section-keyed `$`-document per record; this takes
|
|
5
5
|
// those documents back and renders them to files.
|
|
6
6
|
//
|
|
7
|
-
// Identity & placement
|
|
8
|
-
// -
|
|
9
|
-
// `{ entry: { model, entity: <uuid> }, name: <slug> }
|
|
10
|
-
// (its `$children`) whose `name` names the folder
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
// - the record's directory comes from its `$model` — `
|
|
15
|
-
//
|
|
16
|
-
// directory, and which query selects a record is not a
|
|
7
|
+
// Identity & placement:
|
|
8
|
+
// - a record's slug comes from the FOLDER document — each ref leaf is
|
|
9
|
+
// `{ entry: { model, entity: <uuid> }, name: <slug> }`, at the top of the folder
|
|
10
|
+
// or inside a branch (its `$children`) whose `name` names the sub-folder. The
|
|
11
|
+
// folder is the authoritative organization on a read (the record document's own
|
|
12
|
+
// `$id` envelope is not guaranteed to be echoed back), with the record
|
|
13
|
+
// document's `$id` (its position in the directory) as a fallback when present.
|
|
14
|
+
// - the record's directory comes from its `$model` — `records/{schema}/` is where
|
|
15
|
+
// a record of that model lives (`site.yml::paths.records` moves it). Not from any
|
|
16
|
+
// query: a query has no directory, and which query selects a record is not a
|
|
17
|
+
// fact about it.
|
|
17
18
|
// - an existing local file carrying the same `$uuid` is re-rendered in place;
|
|
18
19
|
// otherwise a new single-record file is placed at `<slug>.<ext>`, its format
|
|
19
|
-
// matched to the
|
|
20
|
+
// matched to the schema folder's existing files, else markdown when the Model's
|
|
20
21
|
// brief has a content body field, else YAML.
|
|
21
22
|
//
|
|
22
23
|
// Field rendering reuses renderEntityDocument (via writeRecordFile) — localized
|
|
23
24
|
// unwrap, date handling, content-body→body are already inverted there.
|
|
24
25
|
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// (a comment-preserving config rewrite is a separate quality bar). The folder itself
|
|
31
|
-
// carries no `$uuid` — the backend owns it, keyed by the site-content uuid — so
|
|
32
|
-
// nothing is written into `collections.yml` here. Nothing is silently dropped: an
|
|
33
|
-
// unplaceable or unresolvable record is reported.
|
|
26
|
+
// Deferred: array-form & BibTeX multi-record files (a pulled record is placed as
|
|
27
|
+
// its own single-record file; merging into an existing array file is a later
|
|
28
|
+
// nicety). The folder itself carries no `$uuid` — the backend owns it, keyed by the
|
|
29
|
+
// site-content uuid. Nothing is silently dropped: an unplaceable or unresolvable
|
|
30
|
+
// record is reported.
|
|
34
31
|
|
|
35
32
|
import { readBackendState, updateBackendMap } from './sync-store.js'
|
|
36
|
-
import { readFileSync, readdirSync, existsSync } from 'node:fs'
|
|
33
|
+
import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs'
|
|
37
34
|
import { join, resolve, relative, extname, basename, sep } from 'node:path'
|
|
38
35
|
import yaml from 'js-yaml'
|
|
39
36
|
import { YAML_OPTIONS } from '../utils/yaml-schema.js'
|
|
40
37
|
import { parseFrontmatter } from './entity-source.js'
|
|
41
38
|
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
42
39
|
import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
|
|
43
|
-
import { poolDirsForSchema,
|
|
40
|
+
import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
|
|
41
|
+
import { recordsYmlPath } from '../site/records-config.js'
|
|
44
42
|
import { isContentBodyField } from './data-schema.js'
|
|
45
43
|
import { unresolveSelfScope } from './self-scope.js'
|
|
46
44
|
import { unwrapLocalized } from './backfill.js'
|
|
@@ -145,7 +143,7 @@ function indexFolder(folderDoc) {
|
|
|
145
143
|
// Where a pulled record is written: the pool folder its MODEL names.
|
|
146
144
|
//
|
|
147
145
|
// ⛔ NOT THE QUERY'S DIRECTORY — a query has none. A record's home is decided by
|
|
148
|
-
// what it IS, and `
|
|
146
|
+
// what it IS, and `records/{schema}/` is the one place a record of that model
|
|
149
147
|
// lives. That is also why the placement survives a query being renamed, added or
|
|
150
148
|
// deleted, none of which is a fact about the record.
|
|
151
149
|
//
|
|
@@ -153,9 +151,9 @@ function indexFolder(folderDoc) {
|
|
|
153
151
|
// `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
|
|
154
152
|
// a pulled record would land somewhere the next build reads as a different
|
|
155
153
|
// schema — silently, because both paths are well-formed.
|
|
156
|
-
function recordDirFor(
|
|
154
|
+
function recordDirFor(recordsRoot, model, selfOrg) {
|
|
157
155
|
const dirs = poolDirsForSchema(unresolveSelfScope(model, selfOrg))
|
|
158
|
-
return dirs ? join(
|
|
156
|
+
return dirs ? join(recordsRoot, ...dirs) : null
|
|
159
157
|
}
|
|
160
158
|
|
|
161
159
|
// ⚠️ Undoing the producer's self-scope resolution (`unresolveSelfScope`) lives in
|
|
@@ -175,7 +173,7 @@ function locate(document, folderIndex) {
|
|
|
175
173
|
return fromFolder || null
|
|
176
174
|
}
|
|
177
175
|
|
|
178
|
-
/** `site.
|
|
176
|
+
/** The site's own org on `backend` — its `site.org` in `sync.json` — bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
|
|
179
177
|
// ⛔ `site.yml::$org` IS GONE (2026-09-20) — the org lives in
|
|
180
178
|
// `sync.json::backends.<origin>.site.org`. This read `site.yml` and, once step 4
|
|
181
179
|
// moved the key, silently returned null: every `@org/x` model a pull met would have
|
|
@@ -263,8 +261,8 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
|
|
|
263
261
|
setIf(decl, 'transform', source.transform)
|
|
264
262
|
setIf(decl, 'record', source.record)
|
|
265
263
|
} else if (typeof source.path === 'string') {
|
|
266
|
-
// ⛔ A FILE-BASED QUERY HAS NO PATH TO WRITE BACK. `
|
|
267
|
-
//
|
|
264
|
+
// ⛔ A FILE-BASED QUERY HAS NO PATH TO WRITE BACK. `records/{schema}/` holds its
|
|
265
|
+
// records and `schema:` addresses them, so a `path` arriving on the wire is either
|
|
268
266
|
// stale storage or something only a remote source could have meant. Dropping
|
|
269
267
|
// it keeps the author's file saying what the build actually reads.
|
|
270
268
|
decl.path = source.path
|
|
@@ -332,8 +330,10 @@ function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
|
|
|
332
330
|
* @param {object} params.document - a site-content `$`-document (`{ queries }`)
|
|
333
331
|
* @param {string} params.siteRoot
|
|
334
332
|
* @param {string} [params.org] - the site's own org, so a `schema` the producer
|
|
335
|
-
* qualified from `@/x` is written back as `@/x`. Defaults to
|
|
336
|
-
* the same default `recordsToProject`
|
|
333
|
+
* qualified from `@/x` is written back as `@/x`. Defaults to the org
|
|
334
|
+
* `sync.json` records for `backend`, the same default `recordsToProject`
|
|
335
|
+
* places records by.
|
|
336
|
+
* @param {string|null} [params.backend] - whose recorded org is that default
|
|
337
337
|
* @returns {{ collections?: 'updated'|'unchanged' }}
|
|
338
338
|
*/
|
|
339
339
|
export function declarationsToQueriesYml({ document, siteRoot, org, backend = null }) {
|
|
@@ -372,31 +372,37 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
|
|
|
372
372
|
/**
|
|
373
373
|
* Project a pulled `@uniweb/folder` document back to `records.yml`.
|
|
374
374
|
*
|
|
375
|
-
* ⭐ THE FOLDER
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
* Taking queries out of the folder is what dissolved that.
|
|
375
|
+
* ⭐ THE FOLDER ROUND-TRIPS TRIVIALLY, and that is by design rather than luck:
|
|
376
|
+
* `records.yml` holds concrete paths on both sides, so there is nothing to invert.
|
|
377
|
+
* The old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
|
|
378
|
+
* collections — and inverting a macro is not possible in general. Taking queries
|
|
379
|
+
* out of the folder is what dissolved that.
|
|
381
380
|
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
381
|
+
* ⭐ ONLY THE SUB-FOLDERS ARE WRITTEN (ruled 2026-09-21 [Diego]). A record at the
|
|
382
|
+
* top of the folder needs no line — being in the records directory is what puts it
|
|
383
|
+
* there — so `records.yml` carries the branches and the records placed in them, and
|
|
384
|
+
* a folder with no branches is a site with no `records.yml`: a local one is REMOVED
|
|
385
|
+
* (it could only describe sub-folders the backend's folder no longer has). No state
|
|
386
|
+
* of the file removes a record, so this cannot empty anything.
|
|
387
|
+
*
|
|
388
|
+
* ⛔ A PULL THAT CARRIED NO FOLDER, or one with a placed record that was not written
|
|
389
|
+
* locally, leaves the file alone: it has nothing true to write.
|
|
386
390
|
*
|
|
387
391
|
* @param {object} params
|
|
388
392
|
* @param {object} params.folderDoc - the stored `@uniweb/folder` document
|
|
389
393
|
* @param {string} params.siteRoot
|
|
390
394
|
* @param {Map<string,string>} params.poolPathByUuid - record `$uuid` → the path
|
|
391
|
-
* under `
|
|
395
|
+
* under `records/` of the file just written for it. Supplied by
|
|
392
396
|
* `recordsToProject`, which is the only thing that knows the extension
|
|
393
397
|
* each record landed with.
|
|
394
|
-
* @returns {{ status: 'updated'|'unchanged'|'skipped', entries: Array, warnings: string[] }}
|
|
398
|
+
* @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[] }}
|
|
395
399
|
*/
|
|
396
400
|
export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
|
|
397
401
|
const warnings = []
|
|
402
|
+
if (!folderDoc) return { status: 'skipped', entries: [], warnings }
|
|
403
|
+
let unplaceable = false
|
|
398
404
|
|
|
399
|
-
const walk = (nodes) => {
|
|
405
|
+
const walk = (nodes, inBranch) => {
|
|
400
406
|
const out = []
|
|
401
407
|
for (const node of nodes || []) {
|
|
402
408
|
if (!node || typeof node !== 'object') continue
|
|
@@ -406,35 +412,53 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
406
412
|
// does not caption its rows. On the wire the label is a localized map;
|
|
407
413
|
// records.yml carries the source-locale string (a bare string passes).
|
|
408
414
|
if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
|
|
409
|
-
entry.records = walk(node.$children)
|
|
415
|
+
entry.records = walk(node.$children, true)
|
|
410
416
|
out.push(entry)
|
|
411
417
|
continue
|
|
412
418
|
}
|
|
413
419
|
const uuid = node.entry?.entity ?? node.entry
|
|
414
420
|
const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
|
|
421
|
+
// A record at the top of the folder needs no line — but one that did not land
|
|
422
|
+
// is still said: the next push sends the folder the directory holds, which
|
|
423
|
+
// would not have it.
|
|
424
|
+
if (!inBranch) {
|
|
425
|
+
if (!rel) {
|
|
426
|
+
warnings.push(
|
|
427
|
+
`the folder holds a record ("${node.name ?? '?'}") that was not written locally — ` +
|
|
428
|
+
`a push from here would remove it from the folder.`
|
|
429
|
+
)
|
|
430
|
+
}
|
|
431
|
+
continue
|
|
432
|
+
}
|
|
415
433
|
if (!rel) {
|
|
416
|
-
// ⚠️ Reported, never dropped in silence. A
|
|
417
|
-
// means the folder and the
|
|
418
|
-
// would
|
|
434
|
+
// ⚠️ Reported, never dropped in silence. A placed record that did not land
|
|
435
|
+
// means the folder and the directory disagree, and writing the file without
|
|
436
|
+
// it would move that record to the top of the folder on the next push.
|
|
419
437
|
warnings.push(
|
|
420
|
-
`records.yml:
|
|
421
|
-
`
|
|
438
|
+
`records.yml: the folder places a record ("${node.name ?? '?'}") that was not ` +
|
|
439
|
+
`written locally — the file was left unchanged rather than dropping it.`
|
|
422
440
|
)
|
|
423
|
-
|
|
441
|
+
unplaceable = true
|
|
442
|
+
continue
|
|
424
443
|
}
|
|
425
444
|
out.push(rel)
|
|
426
445
|
}
|
|
427
446
|
return out
|
|
428
447
|
}
|
|
429
448
|
|
|
430
|
-
const entries = walk(folderDoc
|
|
431
|
-
if (
|
|
432
|
-
if (entries.length === 0)
|
|
449
|
+
const entries = walk(folderDoc.contents, false)
|
|
450
|
+
if (unplaceable) return { status: 'skipped', entries: [], warnings }
|
|
451
|
+
if (entries.length === 0) {
|
|
452
|
+
const file = recordsYmlPath(siteRoot)
|
|
453
|
+
if (!existsSync(file)) return { status: 'unchanged', entries, warnings }
|
|
454
|
+
unlinkSync(file)
|
|
455
|
+
return { status: 'removed', entries, warnings }
|
|
456
|
+
}
|
|
433
457
|
return { status: writeRecordsConfig(siteRoot, entries), entries, warnings }
|
|
434
458
|
}
|
|
435
459
|
|
|
436
460
|
/**
|
|
437
|
-
* Project a pulled folder + its record entities to `
|
|
461
|
+
* Project a pulled folder + its record entities to `records/**` files.
|
|
438
462
|
*
|
|
439
463
|
* @param {object} params
|
|
440
464
|
* @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
|
|
@@ -445,24 +469,31 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
445
469
|
* - resolve a Model's data-schema declaration by name (`$model`).
|
|
446
470
|
* @param {string} [params.opts.org] - the site's own org, so a `@org/x` model the
|
|
447
471
|
* producer resolved from `@/x` is placed back where the author wrote it.
|
|
448
|
-
* Defaults to `
|
|
472
|
+
* Defaults to the org `sync.json` records for `opts.backend`.
|
|
473
|
+
* @param {string} [params.opts.backend] - whose record map to read and extend, and
|
|
474
|
+
* whose recorded org is that default
|
|
449
475
|
* @param {string} [params.opts.sourceLocale]
|
|
450
476
|
* @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
|
|
451
477
|
*/
|
|
452
478
|
export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
|
|
453
479
|
const { resolveDeclaration, sourceLocale = 'en' } = opts
|
|
454
480
|
// The site's own org, so a `@org/x` model the producer resolved from `@/x` is
|
|
455
|
-
// placed back where the author wrote it. Read from `
|
|
456
|
-
// caller already has it.
|
|
481
|
+
// placed back where the author wrote it. Read from `sync.json` (this backend's
|
|
482
|
+
// `site.org`) unless the caller already has it.
|
|
457
483
|
const selfOrg = opts.org ?? readSiteOrg(siteRoot, opts.backend)
|
|
458
484
|
if (typeof resolveDeclaration !== 'function') {
|
|
459
485
|
throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
|
|
460
486
|
}
|
|
461
487
|
|
|
462
488
|
const folderIndex = indexFolder(folderDoc)
|
|
489
|
+
// Where records land — `site.yml::paths.records`, else `records/`: the one resolver
|
|
490
|
+
// the build and the push read too, so a pulled record lands where the next build
|
|
491
|
+
// looks. ⛔ This wrote into the default directory until 2026-09-21, whatever the
|
|
492
|
+
// site had moved its records to.
|
|
493
|
+
const recordsRoot = resolveRecordsDir(siteRoot).abs
|
|
463
494
|
// Captures target-locale translations of localized record fields: SCALARs →
|
|
464
495
|
// locales/records/{locale}.json (structural maps too), and a prosemirror
|
|
465
|
-
// BODY's free-form per-locale override → locales/freeform/{locale}/
|
|
496
|
+
// BODY's free-form per-locale override → locales/freeform/{locale}/records/.
|
|
466
497
|
const collector = createTranslationCollector(sourceLocale)
|
|
467
498
|
const updated = []
|
|
468
499
|
const placed = []
|
|
@@ -474,7 +505,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
474
505
|
const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
|
|
475
506
|
// own id → their uuid, for every record this pull wrote. Recorded at the end.
|
|
476
507
|
const learned = {}
|
|
477
|
-
// uuid → the path under `
|
|
508
|
+
// uuid → the path under `records/` the record landed at. Only this loop knows
|
|
478
509
|
// the extension each one got, so `records.yml` is written from it rather than
|
|
479
510
|
// re-derived (a second rule could pick a different extension and the folder
|
|
480
511
|
// would name a file that is not there).
|
|
@@ -492,7 +523,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
492
523
|
continue
|
|
493
524
|
}
|
|
494
525
|
|
|
495
|
-
const poolDir = recordDirFor(
|
|
526
|
+
const poolDir = recordDirFor(recordsRoot, document.$model, selfOrg)
|
|
496
527
|
if (!poolDir) {
|
|
497
528
|
skipped.push({
|
|
498
529
|
uuid: document.$uuid,
|
|
@@ -542,7 +573,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
542
573
|
// Keyed by THEIR uuid: the folder document references records in the backend's
|
|
543
574
|
// terms, so that is what `folderToRecordsYml` will look them up by.
|
|
544
575
|
if (theirs) {
|
|
545
|
-
poolPathByUuid.set(theirs, relative(
|
|
576
|
+
poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
|
|
546
577
|
if (own) learned[own] = theirs
|
|
547
578
|
}
|
|
548
579
|
}
|