@uniweb/build 0.59.1 → 0.61.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 +5 -5
- package/src/index.js +0 -1
- package/src/resolve-data-schema.js +28 -5
- package/src/schema.js +7 -4
- package/src/uwx/backfill.js +30 -68
- package/src/uwx/emit-surface.json +1 -1
- package/src/uwx/entity-document.js +22 -1
- package/src/uwx/folder.js +13 -9
- package/src/uwx/index.js +1 -1
- package/src/uwx/project-writer.js +49 -6
- package/src/uwx/records-project.js +31 -14
- package/src/uwx/records.js +135 -35
- package/src/uwx/self-scope.js +1 -1
- package/src/uwx/site.js +6 -6
- package/src/uwx/sync-package.js +12 -7
- package/src/validate-data.js +108 -153
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -58,11 +58,11 @@
|
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
60
|
"@uniweb/content-reader": "^1.2.5",
|
|
61
|
-
"@uniweb/schemas": "^0.4.0",
|
|
62
|
-
"@uniweb/theming": "^0.1.15",
|
|
63
61
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/
|
|
62
|
+
"@uniweb/schemas": "^0.6.0",
|
|
63
|
+
"@uniweb/theming": "^0.1.15",
|
|
64
|
+
"@uniweb/projections": "^0.7.3",
|
|
65
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
68
|
"@uniweb/runtime": "^0.27.4"
|
package/src/index.js
CHANGED
|
@@ -287,17 +287,40 @@ async function collectSchemasFromExports(packageDir) {
|
|
|
287
287
|
// Source 2: a bare `schemas/` directory of schema files, one schema per file
|
|
288
288
|
// (named by basename). Returns `{}` when the directory is absent.
|
|
289
289
|
async function collectSchemasFromDir(dir) {
|
|
290
|
-
if (!existsSync(dir)) return {}
|
|
291
290
|
const out = {}
|
|
292
|
-
for (const file of
|
|
293
|
-
const ext = extname(file)
|
|
294
|
-
if (!SCHEMA_EXTENSIONS.includes(ext)) continue
|
|
295
|
-
const name = basename(file, ext)
|
|
291
|
+
for (const { file, name } of schemaFilesIn(dir)) {
|
|
296
292
|
out[`@/${name}`] = validateAndNormalizeSchema(await loadSchemaFile(join(dir, file)), `@/${name}`)
|
|
297
293
|
}
|
|
298
294
|
return out
|
|
299
295
|
}
|
|
300
296
|
|
|
297
|
+
// The schema files directly in a folder, one schema per file, named by basename.
|
|
298
|
+
// ⭐ A leading `_` leaves a file out — a draft — as it does for a record.
|
|
299
|
+
function schemaFilesIn(dir) {
|
|
300
|
+
if (!existsSync(dir)) return []
|
|
301
|
+
return readdirSync(dir)
|
|
302
|
+
.sort()
|
|
303
|
+
.filter((file) => SCHEMA_EXTENSIONS.includes(extname(file)) && !file.startsWith('_'))
|
|
304
|
+
.map((file) => ({ file, name: basename(file, extname(file)) }))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* The data schemas a foundation DEFINES: one `@/<name>` per file in its `schemas/`
|
|
309
|
+
* folder, a file named with a leading `_` left out.
|
|
310
|
+
*
|
|
311
|
+
* ⭐ THE FOLDER IS THE DECLARATION [Diego, 2026-09-24] — the same rule as for a
|
|
312
|
+
* record: placing the file there is what makes it one. Until then a foundation's
|
|
313
|
+
* data schemas were only the ones a section binding reached, so a type only the app
|
|
314
|
+
* uses — a lesson, a quiz, a learner's progress, which no section renders — was
|
|
315
|
+
* never in `schema.json` and never registered.
|
|
316
|
+
*
|
|
317
|
+
* @param {string} srcDir - the foundation's source root
|
|
318
|
+
* @returns {string[]}
|
|
319
|
+
*/
|
|
320
|
+
export function ownSchemaRefs(srcDir) {
|
|
321
|
+
return schemaFilesIn(join(srcDir, 'schemas')).map(({ name }) => `@/${name}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
301
324
|
// Resolve a package's module entry FILE (absolute) from its package.json —
|
|
302
325
|
// `exports['.']` (string or a conditional import/default/node), else `main`, else
|
|
303
326
|
// `index.js`. Loads a schemas package's own exports without self-resolution.
|
package/src/schema.js
CHANGED
|
@@ -17,7 +17,7 @@ import { isFontVar } from '@uniweb/theming'
|
|
|
17
17
|
import { join, dirname, extname, basename } from 'node:path'
|
|
18
18
|
import { pathToFileURL } from 'node:url'
|
|
19
19
|
import { inferTitle } from './utils/infer-title.js'
|
|
20
|
-
import { collectSchemaRefs, buildDataSchemaMap } from './resolve-data-schema.js'
|
|
20
|
+
import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
|
|
21
21
|
import {
|
|
22
22
|
composeSupports,
|
|
23
23
|
deriveRecordsSupport,
|
|
@@ -960,10 +960,13 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
|
|
|
960
960
|
// Discover section types
|
|
961
961
|
const components = await discoverComponents(srcDir, sectionPaths)
|
|
962
962
|
|
|
963
|
-
// Resolve the data schemas
|
|
964
|
-
//
|
|
963
|
+
// Resolve the data schemas the foundation DEFINES — every file in its `schemas/`
|
|
964
|
+
// folder (`ownSchemaRefs`) — and the ones its section bindings reference, carried
|
|
965
|
+
// in schema.json for the editor/platform (see named-data-schemas.md); `register`
|
|
966
|
+
// submits the `@/` ones. Bound refs come first, so a foundation whose schemas are
|
|
967
|
+
// all bound builds the same schema.json it always did.
|
|
965
968
|
const dataSchemas = await buildDataSchemaMap(
|
|
966
|
-
collectSchemaRefs(components, foundationConfig.data),
|
|
969
|
+
new Set([...collectSchemaRefs(components, foundationConfig.data), ...ownSchemaRefs(srcDir)]),
|
|
967
970
|
{ srcDir },
|
|
968
971
|
)
|
|
969
972
|
|
package/src/uwx/backfill.js
CHANGED
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
// canonical). It reads from the SOURCE file (not the backend's finalized document),
|
|
13
13
|
// so field values round-trip untouched and no inverse decode is needed in v1.
|
|
14
14
|
//
|
|
15
|
+
// ⛔ A PUSH NEVER RENDERS THE BACKEND'S DOCUMENT OVER THE AUTHOR'S FILE. It did, for
|
|
16
|
+
// a record's first push, from 2026-05-29 to 2026-09-23 ("variant A"), and that
|
|
17
|
+
// document is not the author's file: it carries the serve URL the push put where the
|
|
18
|
+
// author wrote `/images/x.png`, and only the brief section's declared fields. So a
|
|
19
|
+
// first push rewrote the record's images to a backend route and deleted every key the
|
|
20
|
+
// Model does not declare, and every field of any other section. What a backend holds
|
|
21
|
+
// reaches the file on a pull (`records-project.js`), which restores asset paths first.
|
|
22
|
+
//
|
|
15
23
|
// Single-record YAML/JSON/markdown files are rendered/back-filled in place.
|
|
16
24
|
// Multi-record files — array-form YAML/JSON and BibTeX (many records in one
|
|
17
25
|
// file) — are grouped by file and written once, one `$uuid` per record keyed by
|
|
@@ -213,27 +221,19 @@ function briefSectionOf(declaration) {
|
|
|
213
221
|
return entry ? { name: entry[0], ...entry[1] } : null
|
|
214
222
|
}
|
|
215
223
|
|
|
216
|
-
// Whether a Model's brief section declares a CONTENT body field (a markup `text`
|
|
217
|
-
// field, or a `format: prosemirror` json field) — the md-body target. A markdown
|
|
218
|
-
// source file can only be safely rendered from the document when its body has a
|
|
219
|
-
// field home — otherwise the body would be lost (variant B then).
|
|
220
|
-
function briefHasContentBody(declaration) {
|
|
221
|
-
const brief = briefSectionOf(declaration)
|
|
222
|
-
return Object.values(brief?.fields || {}).some((f) => isContentBodyField(f))
|
|
223
|
-
}
|
|
224
|
-
|
|
225
224
|
/**
|
|
226
|
-
* Render
|
|
227
|
-
*
|
|
228
|
-
*
|
|
225
|
+
* Render an entity `document` a pull brought back to its source-file authoring shape
|
|
226
|
+
* — the file becomes a projection of the backend's state, which is what a pull is
|
|
227
|
+
* (`writeRecordFile`, `records-project.js`). A push never uses it (header ⛔). For a
|
|
228
|
+
* flat/brief entity: the entity `$uuid` + the brief section's fields (localized unwrapped,
|
|
229
229
|
* date/scalars verbatim), with the brief record's own `$uuid` DROPPED (the backend
|
|
230
|
-
* matches a single-section item by singularity) and `$
|
|
230
|
+
* matches a single-section item by singularity) and `$schema`/`$id`/`$meta` omitted.
|
|
231
231
|
* A disabled entity (`$disabled: true`) writes `draft: true`, and an enabled one writes
|
|
232
232
|
* no `draft:` at all.
|
|
233
233
|
* For markdown, the content body field becomes the body; for YAML/JSON it stays a field.
|
|
234
234
|
*
|
|
235
235
|
* @param {object} params
|
|
236
|
-
* @param {object} params.document -
|
|
236
|
+
* @param {object} params.document - pulled `{ $uuid, $schema, <brief>: {…} }`
|
|
237
237
|
* @param {object} params.declaration - the Model declaration (`brief` + `sections`)
|
|
238
238
|
* @param {string} params.format - 'yaml' | 'json' | 'md'
|
|
239
239
|
* @param {string} [params.sourceLocale]
|
|
@@ -296,40 +296,26 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
|
|
|
296
296
|
return yaml.dump(record) // yaml / yaml
|
|
297
297
|
}
|
|
298
298
|
|
|
299
|
-
// Write text only when it differs from what's on disk (idempotent).
|
|
300
|
-
function writeIfChanged(filePath, text) {
|
|
301
|
-
let current = ''
|
|
302
|
-
try {
|
|
303
|
-
current = readFileSync(filePath, 'utf8')
|
|
304
|
-
} catch {
|
|
305
|
-
// new file / unreadable — treat as a change
|
|
306
|
-
}
|
|
307
|
-
if (text === current) return 'unchanged'
|
|
308
|
-
writeFileSync(filePath, text)
|
|
309
|
-
return 'updated'
|
|
310
|
-
}
|
|
311
|
-
|
|
312
299
|
/**
|
|
313
300
|
* Back-fill the sync response into the source files. Correlation is by **`index`**
|
|
314
301
|
* — `finalized[i].index` is the 0-based position of the entity in the submitted
|
|
315
302
|
* sequence, which equals the producer's `index` array order (the backend does not
|
|
316
|
-
* echo `$id`).
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* get a per-entry `$uuid` keyed by slug, grouped so each file is written once;
|
|
321
|
-
* BibTeX stays deferred.
|
|
303
|
+
* echo `$id`). A single-record file gains the entity `$uuid` and nothing else —
|
|
304
|
+
* never the returned `document` (see the ⛔ in the header). Multi-record YAML/JSON
|
|
305
|
+
* files get a per-entry `$uuid` keyed by slug, and BibTeX one per cite key, grouped
|
|
306
|
+
* so each file is written once.
|
|
322
307
|
*
|
|
323
308
|
* @param {object} params
|
|
324
309
|
* @param {object[]} params.index - the emitter's per-entity index, in submit
|
|
325
|
-
* order: `{ id, model, slug, sourceFile, format?, multiRecord?,
|
|
310
|
+
* order: `{ id, model, slug, sourceFile, format?, multiRecord?, ownId?, draft? }`.
|
|
326
311
|
* @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
|
|
327
|
-
*
|
|
312
|
+
* `document` is read for one thing only: whether a record sent as a draft
|
|
313
|
+
* came back disabled.
|
|
328
314
|
* @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string>, notKeptAsDrafts: string[] }}
|
|
329
315
|
* `mapped` is own id → the uuid this backend minted, for the caller to record per backend.
|
|
330
316
|
* `notKeptAsDrafts` names records sent as drafts whose returned document is not disabled.
|
|
331
317
|
*/
|
|
332
|
-
export function backfillEntityUuids({ index, finalized
|
|
318
|
+
export function backfillEntityUuids({ index, finalized }) {
|
|
333
319
|
const updated = []
|
|
334
320
|
const unchanged = []
|
|
335
321
|
const deferred = []
|
|
@@ -364,10 +350,9 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
364
350
|
// ⛔ A DRAFT THE BACKEND DID NOT KEEP AS ONE. A backend is obliged to echo
|
|
365
351
|
// `$disabled: true` on the document of an entity it stored disabled. One that predates
|
|
366
352
|
// the key skips it without a word and stores the record enabled, so it is delivered
|
|
367
|
-
// once the site is published.
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
// with no document there is nothing to judge.
|
|
353
|
+
// once the site is published. The file keeps its `draft: true` — only the uuid is
|
|
354
|
+
// written — and the caller is told. Only a returned document that lacks the flag
|
|
355
|
+
// counts: with no document there is nothing to judge.
|
|
371
356
|
const draftNotKept = entry.draft === true && fin.document && fin.document.$disabled !== true
|
|
372
357
|
if (draftNotKept) notKeptAsDrafts.push(entry.sourceFile || entry.id)
|
|
373
358
|
if (!entry.sourceFile) {
|
|
@@ -383,9 +368,9 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
383
368
|
|
|
384
369
|
// ⛔ A record that already carries its OWN id is NOT rewritten. Its id is stable
|
|
385
370
|
// and travels with the file; this backend's uuid belongs in the map, not in the
|
|
386
|
-
// author's file. And it must not be:
|
|
387
|
-
// the
|
|
388
|
-
//
|
|
371
|
+
// author's file. And it must not be: the write below puts THIS backend's `$uuid` in
|
|
372
|
+
// the file, so a second backend would silently overwrite the record's identity
|
|
373
|
+
// with its own.
|
|
389
374
|
if (entry.ownId) {
|
|
390
375
|
ownIdFiles.add(entry.sourceFile)
|
|
391
376
|
continue
|
|
@@ -404,31 +389,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
404
389
|
continue
|
|
405
390
|
}
|
|
406
391
|
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
// field for its body). Otherwise variant B: back-fill the uuid in place.
|
|
410
|
-
const canRenderA =
|
|
411
|
-
!draftNotKept &&
|
|
412
|
-
fin.document &&
|
|
413
|
-
entry.declaration &&
|
|
414
|
-
(entry.format !== 'md' || briefHasContentBody(entry.declaration))
|
|
415
|
-
let res
|
|
416
|
-
if (canRenderA) {
|
|
417
|
-
try {
|
|
418
|
-
const text = renderEntityDocument({
|
|
419
|
-
document: fin.document,
|
|
420
|
-
declaration: entry.declaration,
|
|
421
|
-
format: entry.format,
|
|
422
|
-
sourceLocale,
|
|
423
|
-
})
|
|
424
|
-
res = { status: writeIfChanged(entry.sourceFile, text) }
|
|
425
|
-
} catch (err) {
|
|
426
|
-
warnings.push(`${entry.sourceFile}: ${err.message}; fell back to uuid back-fill`)
|
|
427
|
-
res = backfillUuid(entry.sourceFile, uuid)
|
|
428
|
-
}
|
|
429
|
-
} else {
|
|
430
|
-
res = backfillUuid(entry.sourceFile, uuid)
|
|
431
|
-
}
|
|
392
|
+
// The uuid, into the author's own file — never the returned document (header ⛔).
|
|
393
|
+
const res = backfillUuid(entry.sourceFile, uuid)
|
|
432
394
|
if (res.status === 'updated') updated.push(entry.sourceFile)
|
|
433
395
|
else if (res.status === 'unchanged') unchanged.push(entry.sourceFile)
|
|
434
396
|
else if (res.status === 'deferred') deferred.push({ index: i, id: entry.id, file: entry.sourceFile, reason: res.message })
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Distinct from package.js's emitEntityPackage (the register / site-content lane,
|
|
4
4
|
// whose per-entity body is the legacy `items[]` shape). The sync lane's per-entity
|
|
5
5
|
// body is the section-keyed `$`-document (see docs/reference/entity-content.md):
|
|
6
|
-
// `$uuid?` then `$id` `$
|
|
6
|
+
// `$uuid?` then `$id` `$schema`, then one key per top-level section.
|
|
7
7
|
//
|
|
8
8
|
// Identity rides in the BODY — the backend reads `$id` always, `$uuid` when
|
|
9
9
|
// present, and MINTS `$uuid` on first sync. The manifest is the INDEX, not a copy
|
|
@@ -25,6 +25,27 @@ import {
|
|
|
25
25
|
computePackageSha256,
|
|
26
26
|
} from './manifest.js'
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The data schema a `$`-document names — its scoped name (`@scope/name`), or null.
|
|
30
|
+
*
|
|
31
|
+
* ⭐ `$schema` is the key, agreed with backend on 2026-09-24 to match the query wire's
|
|
32
|
+
* `schema` for the same value: what the two lanes exchange is a data schema, and the
|
|
33
|
+
* scoped name is how we address one in the registry of the backend we talk to. ⛔ Not
|
|
34
|
+
* an identity ACROSS backends — each has its own registry, so one name on two of them
|
|
35
|
+
* names two data schemas [Diego, 2026-09-24]. It was `$model` until
|
|
36
|
+
* then, and ⛔ nothing reads that spelling — no production backend existed to keep a
|
|
37
|
+
* window for [Diego, 2026-09-24]. The value comes back as it is, so a caller can still
|
|
38
|
+
* tell an id from a name and say which it met.
|
|
39
|
+
* Record: `kb/framework/build/uwx-format.md` § *The data schema an entity names*.
|
|
40
|
+
*
|
|
41
|
+
* @param {object} doc - an entity `$`-document
|
|
42
|
+
* @returns {string|null}
|
|
43
|
+
*/
|
|
44
|
+
export function documentSchema(doc) {
|
|
45
|
+
const value = doc?.$schema
|
|
46
|
+
return typeof value === 'string' && value ? value : null
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
// `exporter.instance`: caller-override -> env UNIWEB_INSTANCE_ID -> "unknown".
|
|
29
50
|
// Provenance only; neutralized in the digest, so its value is inert.
|
|
30
51
|
function defaultExporter() {
|
package/src/uwx/folder.js
CHANGED
|
@@ -52,18 +52,22 @@ export const FOLDER_MODEL_NAME = '@uniweb/folder'
|
|
|
52
52
|
export const FOLDER_ENTITY_KEY = '@folder'
|
|
53
53
|
|
|
54
54
|
// Point one authored leaf at the record entity it names. The folder's `contents`
|
|
55
|
-
// field is polymorphic (it can reference any
|
|
56
|
-
// entity_ref OPEN form `{
|
|
57
|
-
// valid when the field pins a single
|
|
55
|
+
// field is polymorphic (it can reference any data schema), so the ref uses the
|
|
56
|
+
// entity_ref OPEN form `{ schema, entity }` — not a bare uuid (a bare uuid is only
|
|
57
|
+
// valid when the field pins a single schema). Known uuid → `entry: { schema, entity
|
|
58
58
|
// }`; brand-new → `$ref` handle (resolved within this payload to the minted
|
|
59
59
|
// entity).
|
|
60
60
|
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
61
|
+
// ⭐ `schema` carries the data schema's SCOPED NAME (`@std/article`), never a Model id
|
|
62
|
+
// — agreed with backend 2026-09-24: a Model id is the backend's internal identity and
|
|
63
|
+
// does not cross `/dev`, while a scoped name is how we address a data schema in the
|
|
64
|
+
// registry of the backend we talk to. ⛔ Neither is an identity across backends: each
|
|
65
|
+
// has its own registry [Diego, 2026-09-24]. (A TODO here asked
|
|
66
|
+
// for the id until then; the agreement settled it the other way.) The key was `model`
|
|
67
|
+
// until the same date. Record: `kb/framework/build/uwx-format.md` § 8.
|
|
64
68
|
function refLeaf(entity) {
|
|
65
69
|
const leaf = { kind: 'ref', name: entity.slug }
|
|
66
|
-
if (entity.uuid) leaf.entry = {
|
|
70
|
+
if (entity.uuid) leaf.entry = { schema: entity.model, entity: entity.uuid }
|
|
67
71
|
else leaf.$ref = entity.id // the payload-local handle
|
|
68
72
|
return leaf
|
|
69
73
|
}
|
|
@@ -103,7 +107,7 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
|
|
|
103
107
|
return out
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
// The uuid of the record a leaf references — once minted, `entry: {
|
|
110
|
+
// The uuid of the record a leaf references — once minted, `entry: { schema, entity }`
|
|
107
111
|
// (a bare uuid is tolerated). A brand-new record's leaf carries `$ref` instead.
|
|
108
112
|
function recordOf(item) {
|
|
109
113
|
if (item?.kind !== 'ref') return null
|
|
@@ -255,7 +259,7 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
|
|
|
255
259
|
|
|
256
260
|
const document = {
|
|
257
261
|
$id: FOLDER_ENTITY_KEY,
|
|
258
|
-
$
|
|
262
|
+
$schema: FOLDER_MODEL_NAME,
|
|
259
263
|
contents,
|
|
260
264
|
}
|
|
261
265
|
// Re-arm placement identity. Without it a second send reads as "every item is
|
package/src/uwx/index.js
CHANGED
|
@@ -21,7 +21,7 @@ export {
|
|
|
21
21
|
PACKAGE_SHA256_ASSUMPTIONS,
|
|
22
22
|
} from './manifest.js'
|
|
23
23
|
export { emitEntityPackage } from './package.js'
|
|
24
|
-
export { emitEntitySyncPackage } from './entity-document.js'
|
|
24
|
+
export { emitEntitySyncPackage, documentSchema } from './entity-document.js'
|
|
25
25
|
export {
|
|
26
26
|
FOUNDATION_SCHEMA_TYPE_UUID,
|
|
27
27
|
SITE_CONTENT_TYPE_UUID,
|
|
@@ -212,7 +212,12 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
|
212
212
|
if (value === null || value === undefined) {
|
|
213
213
|
delete existing[key]
|
|
214
214
|
} else if (typeof value === 'object' && !Array.isArray(value) && !replace.includes(key)) {
|
|
215
|
-
|
|
215
|
+
// ⛔ Merged only INTO AN OBJECT. Spread, a string becomes its characters: a
|
|
216
|
+
// pull wrote `events: '@/event'` back as `events: { '0': '@', '1': '/', …,
|
|
217
|
+
// schema: '@/event' }` (measured 2026-09-23). Anything else that is not an
|
|
218
|
+
// object has nothing to keep, so the incoming value replaces it.
|
|
219
|
+
const prior = existing[key]
|
|
220
|
+
existing[key] = isPlainObject(prior) ? { ...prior, ...value } : value
|
|
216
221
|
} else {
|
|
217
222
|
existing[key] = value
|
|
218
223
|
}
|
|
@@ -220,6 +225,8 @@ function mergeYamlConfig(filePath, changes, { replace = [] } = {}) {
|
|
|
220
225
|
return writeIfChanged(filePath, yaml.dump(existing, YAML_DUMP_OPTS))
|
|
221
226
|
}
|
|
222
227
|
|
|
228
|
+
const isPlainObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v)
|
|
229
|
+
|
|
223
230
|
/**
|
|
224
231
|
* Merge `config` into `site.yml` (shallow). Preserves keys not present in the
|
|
225
232
|
* update (foundation, base, paths, …).
|
|
@@ -290,9 +297,45 @@ export function writeMergedYaml(filePath, projected, managedKeys) {
|
|
|
290
297
|
* @returns {'updated'|'unchanged'}
|
|
291
298
|
*/
|
|
292
299
|
export function writeQueriesConfig(siteRoot, queries) {
|
|
293
|
-
|
|
300
|
+
const filePath = queriesYmlPath(siteRoot)
|
|
301
|
+
let authored = {}
|
|
302
|
+
try {
|
|
303
|
+
authored = yaml.load(readFileSync(filePath, 'utf8'), YAML_OPTIONS) || {}
|
|
304
|
+
} catch {
|
|
305
|
+
// missing / invalid → nothing authored to keep
|
|
306
|
+
}
|
|
307
|
+
// ⭐ A query the file already states is left as the author wrote it — in the same
|
|
308
|
+
// words, or in a shorthand (`team: '@/person'` is `{ schema: '@/person' }`, a bare
|
|
309
|
+
// `events:` is `{}`), which a pull brings back in its long form. And a pull that
|
|
310
|
+
// restates every query writes nothing: the file is re-dumped whole otherwise, which
|
|
311
|
+
// rewrites a bare `events:` as `events: null` and flow style as block style.
|
|
312
|
+
const changes = {}
|
|
313
|
+
for (const [name, decl] of Object.entries(queries)) {
|
|
314
|
+
if (restates(authored?.[name], decl)) continue
|
|
315
|
+
changes[name] = decl
|
|
316
|
+
}
|
|
317
|
+
if (Object.keys(changes).length === 0) return 'unchanged'
|
|
318
|
+
// ⛔ Each query WHOLE (`replace`), as this function's header always said. Merged into
|
|
319
|
+
// the local one — what the call did until 2026-09-23 — a key the backend's
|
|
320
|
+
// declaration no longer has survived every pull: the defect `writeSiteConfig`
|
|
321
|
+
// records for the declaration keys ("a stale `limit` survived every pull").
|
|
322
|
+
return mergeYamlConfig(filePath, changes, { replace: Object.keys(changes) })
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Whether a pulled declaration says only what the author's entry already says.
|
|
326
|
+
function restates(authored, decl) {
|
|
327
|
+
const keys = Object.keys(decl || {})
|
|
328
|
+
if (typeof authored === 'string') return keys.length === 1 && decl.schema === authored
|
|
329
|
+
if (authored === null) return keys.length === 0
|
|
330
|
+
return isPlainObject(authored) && canonicalJson(authored) === canonicalJson(decl)
|
|
294
331
|
}
|
|
295
332
|
|
|
333
|
+
// JSON with every object's keys sorted — equal for equal values, whatever the order.
|
|
334
|
+
const canonicalJson = (value) =>
|
|
335
|
+
JSON.stringify(value, (_, v) =>
|
|
336
|
+
isPlainObject(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, v[k]])) : v
|
|
337
|
+
)
|
|
338
|
+
|
|
296
339
|
/**
|
|
297
340
|
* Write the records folder's organization — `folder.yml` in the records directory
|
|
298
341
|
* (`records/`, or wherever `site.yml::paths.records` puts it), as a LIST.
|
|
@@ -324,13 +367,13 @@ export function writeThemeFile(siteRoot, theme) {
|
|
|
324
367
|
}
|
|
325
368
|
|
|
326
369
|
/**
|
|
327
|
-
* Render a
|
|
328
|
-
* (
|
|
329
|
-
*
|
|
370
|
+
* Render a collection-record `document` a pull brought back to its source-file shape
|
|
371
|
+
* (via renderEntityDocument) and write it idempotently. The record half of the
|
|
372
|
+
* collections lane's write step.
|
|
330
373
|
*
|
|
331
374
|
* @param {object} opts
|
|
332
375
|
* @param {string} opts.filePath
|
|
333
|
-
* @param {object} opts.document -
|
|
376
|
+
* @param {object} opts.document - pulled `{ $uuid, $schema, <brief>: {…} }`
|
|
334
377
|
* @param {object} opts.declaration - the record's data-schema declaration
|
|
335
378
|
* @param {'yaml'|'json'|'md'} opts.format
|
|
336
379
|
* @param {string} [opts.sourceLocale]
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Identity & placement:
|
|
8
8
|
// - a record's slug comes from the FOLDER document — each ref leaf is
|
|
9
|
-
// `{ entry: {
|
|
9
|
+
// `{ entry: { schema, entity: <uuid> }, name: <slug> }`, at the top of the folder
|
|
10
10
|
// or inside a branch (its `$children`) whose `name` names the sub-folder. The
|
|
11
11
|
// folder is the authoritative organization on a read (the record document's own
|
|
12
12
|
// `$id` envelope is not guaranteed to be echoed back), with the record
|
|
13
13
|
// document's `$id` (its position in the directory) as a fallback when present.
|
|
14
|
-
// - the record's directory comes from its
|
|
14
|
+
// - the record's directory comes from its data schema (`$schema`, read by
|
|
15
|
+
// `documentSchema`) — `records/{schema}/` is where
|
|
15
16
|
// a record of that model lives (`site.yml::paths.records` moves it). Not from any
|
|
16
17
|
// query: a query has no directory, and which query selects a record is not a
|
|
17
18
|
// fact about it.
|
|
@@ -21,7 +22,8 @@
|
|
|
21
22
|
// brief has a content body field, else YAML.
|
|
22
23
|
//
|
|
23
24
|
// Field rendering reuses renderEntityDocument (via writeRecordFile) — localized
|
|
24
|
-
// unwrap, date handling, content-body→body are already inverted there.
|
|
25
|
+
// unwrap, date handling, content-body→body are already inverted there. Asset paths
|
|
26
|
+
// are restored before it runs (`restoreAssetRefs`), as the content lane does.
|
|
25
27
|
//
|
|
26
28
|
// Deferred: array-form & BibTeX multi-record files (a pulled record is placed as
|
|
27
29
|
// its own single-record file; merging into an existing array file is a later
|
|
@@ -30,6 +32,8 @@
|
|
|
30
32
|
// record is reported.
|
|
31
33
|
|
|
32
34
|
import { readBackendState, updateBackendMap } from './sync-store.js'
|
|
35
|
+
import { restoreAssetRefs } from './asset-map.js'
|
|
36
|
+
import { documentSchema } from './entity-document.js'
|
|
33
37
|
import { readFileSync, readdirSync, existsSync, unlinkSync } from 'node:fs'
|
|
34
38
|
import { join, resolve, relative, extname, basename, sep } from 'node:path'
|
|
35
39
|
import yaml from 'js-yaml'
|
|
@@ -116,7 +120,7 @@ function briefHasContentBody(declaration) {
|
|
|
116
120
|
// folder is a self-nesting tree under `contents`, nesting via `$children` (the
|
|
117
121
|
// site-content invariant — folder.js). A leaf sits in a branch whose `name` is
|
|
118
122
|
// the collection; the leaf's `name` is the slug (the handle) and its `entry` is
|
|
119
|
-
// the entity_ref open form `{
|
|
123
|
+
// the entity_ref open form `{ schema, entity: <uuid> }`. ⛔ `path_segment` is not
|
|
120
124
|
// read: the store renamed it on 2026-09-04 and a pull emits the new shape only —
|
|
121
125
|
// a reader that kept the old key would index every record as
|
|
122
126
|
// `{ folderPath: null, slug: undefined }` and rewrite records.yml with
|
|
@@ -131,7 +135,7 @@ function indexFolder(folderDoc) {
|
|
|
131
135
|
if (node?.kind === 'branch') {
|
|
132
136
|
walk(node.$children, node.name ?? folderPath)
|
|
133
137
|
} else if (node?.kind === 'ref' && node.entry) {
|
|
134
|
-
// `entry` is `{
|
|
138
|
+
// `entry` is `{ schema, entity: <uuid> }`; tolerate a bare uuid defensively.
|
|
135
139
|
const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
|
|
136
140
|
if (uuid) byUuid.set(uuid, { folderPath, slug: node.name })
|
|
137
141
|
}
|
|
@@ -456,11 +460,11 @@ export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceL
|
|
|
456
460
|
*
|
|
457
461
|
* @param {object} params
|
|
458
462
|
* @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
|
|
459
|
-
* @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $
|
|
463
|
+
* @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $schema, <brief> }`
|
|
460
464
|
* @param {string} params.siteRoot
|
|
461
465
|
* @param {object} params.opts
|
|
462
466
|
* @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
|
|
463
|
-
* - resolve a
|
|
467
|
+
* - resolve a data schema's declaration by its scoped name (`documentSchema`).
|
|
464
468
|
* @param {string|null} [params.opts.scope] - the scope the push qualified a record's
|
|
465
469
|
* `@/x` Model with — the site's foundation's — so a `@scope/x` model is placed
|
|
466
470
|
* back where the author wrote it. The caller resolves it up front, as it does
|
|
@@ -495,8 +499,16 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
495
499
|
const unchanged = []
|
|
496
500
|
const skipped = []
|
|
497
501
|
const warnings = []
|
|
502
|
+
const backendState = opts.backend ? readBackendState(siteRoot, opts.backend) : {}
|
|
503
|
+
// ⛔ PUT THE AUTHOR'S ASSET PATHS BACK BEFORE ANYTHING IS RENDERED — the step the
|
|
504
|
+
// content lane takes (`site-project.js`), for the same reason. A pushed record goes
|
|
505
|
+
// up with the serve URL the push put where its author wrote `/images/x.png`, and it
|
|
506
|
+
// comes back that way; rendered as is, a pull rewrites the author's file to a
|
|
507
|
+
// backend route. Asset ids and fingerprints are per backend, so a projection tied
|
|
508
|
+
// to none restores nothing. Missing here until 2026-09-23.
|
|
509
|
+
restoreAssetRefs(recordDocs, backendState.assets || {})
|
|
498
510
|
// This backend's record map, inverted: its uuid → the record's own id.
|
|
499
|
-
const recordMap =
|
|
511
|
+
const recordMap = backendState.records || {}
|
|
500
512
|
const ownIdByTheirs = new Map(Object.entries(recordMap).map(([own, theirs]) => [theirs, own]))
|
|
501
513
|
// own id → their uuid, for every record this pull wrote. Recorded at the end.
|
|
502
514
|
const learned = {}
|
|
@@ -512,18 +524,19 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
512
524
|
skipped.push({ uuid: document.$uuid, reason: 'no slug (not in the folder, no $id)' })
|
|
513
525
|
continue
|
|
514
526
|
}
|
|
515
|
-
const
|
|
527
|
+
const schema = documentSchema(document)
|
|
528
|
+
const declaration = schema ? resolveDeclaration(schema) : null
|
|
516
529
|
if (!declaration) {
|
|
517
|
-
skipped.push({ uuid: document.$uuid, slug: where.slug, reason: `unresolved model ${
|
|
530
|
+
skipped.push({ uuid: document.$uuid, slug: where.slug, reason: `unresolved model ${schema || '(none)'}` })
|
|
518
531
|
continue
|
|
519
532
|
}
|
|
520
533
|
|
|
521
|
-
const poolDir = recordDirFor(recordsRoot,
|
|
534
|
+
const poolDir = recordDirFor(recordsRoot, schema, selfScope)
|
|
522
535
|
if (!poolDir) {
|
|
523
536
|
skipped.push({
|
|
524
537
|
uuid: document.$uuid,
|
|
525
538
|
slug: where.slug,
|
|
526
|
-
reason: `model ${
|
|
539
|
+
reason: `model ${schema} names no pool folder (expected @/name or @org/name)`,
|
|
527
540
|
})
|
|
528
541
|
continue
|
|
529
542
|
}
|
|
@@ -550,7 +563,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
550
563
|
|
|
551
564
|
// The free-form home for this record's content body (locale-independent); a
|
|
552
565
|
// target-locale full-doc body is written under locales/freeform/{locale}/here.
|
|
553
|
-
const freeformRelPath = buildFreeformRecordPath(
|
|
566
|
+
const freeformRelPath = buildFreeformRecordPath(schema, where.slug)
|
|
554
567
|
|
|
555
568
|
// ⛔ The file gets the record's OWN id, never this backend's — a pull from a second
|
|
556
569
|
// backend must not overwrite the identity the record already has.
|
|
@@ -559,7 +572,11 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
559
572
|
try {
|
|
560
573
|
status = writeRecordFile({ filePath, document: toWrite, declaration, format, sourceLocale, collector, freeformRelPath })
|
|
561
574
|
} catch (err) {
|
|
562
|
-
|
|
575
|
+
// ⛔ A record that could not be written was not placed, so it is a SKIP, not a
|
|
576
|
+
// warning: a caller that counts what it placed (the CLI's pull) must see it.
|
|
577
|
+
// Until 2026-09-24 it was a warning, and a pull over records with no Sections —
|
|
578
|
+
// what a partly applied push left — reported every one of them as taken.
|
|
579
|
+
skipped.push({ uuid: document.$uuid, slug: where.slug, reason: err.message })
|
|
563
580
|
continue
|
|
564
581
|
}
|
|
565
582
|
if (status === 'unchanged') unchanged.push(filePath)
|