@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.59.1",
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/content-writer": "^0.3.4",
65
- "@uniweb/projections": "^0.7.3"
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
@@ -26,7 +26,6 @@ export {
26
26
  export {
27
27
  validateItem,
28
28
  validateDataInputs,
29
- validateConceptBlocks,
30
29
  validateTaggedDataBlocks,
31
30
  isStaticallyCheckable,
32
31
  } from './validate-data.js'
@@ -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 readdirSync(dir).sort()) {
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 referenced by section bindings (carried in
964
- // schema.json for the editor/platform — see named-data-schemas.md).
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
 
@@ -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 a finalized entity `document` back to its source-file authoring shape
227
- * (variant A — the file becomes a projection of backend state). For a flat/brief
228
- * entity: the entity `$uuid` + the brief section's fields (localized unwrapped,
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 `$model`/`$id`/`$meta` omitted.
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 - finalized `{ $uuid, $model, <brief>: {…} }`
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`). Single-record files are rendered from the finalized `document`
317
- * (variant A) when the document + declaration are present and lossless for the
318
- * format (markdown needs a content body field to carry the body); otherwise the
319
- * entity `$uuid` is back-filled in place (variant B). Multi-record YAML/JSON files
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?, declaration? }`.
310
+ * order: `{ id, model, slug, sourceFile, format?, multiRecord?, ownId?, draft? }`.
326
311
  * @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
327
- * @param {string} [params.sourceLocale]
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, sourceLocale = 'en' }) {
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. Rendering that document over the file (variant A) would
368
- // then erase `draft: true` from the author's file as well, so the file keeps its own
369
- // text and the caller is told. Only a returned document that lacks the flag counts:
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: variant A below re-renders the WHOLE file from
387
- // the backend's document, which carries THIS backend's `$uuid` — so a second
388
- // backend would silently overwrite the record's identity with its own.
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
- // Variant A: render the finalized document over the file when we have it +
408
- // the declaration, and it's lossless for the format (md needs a content body
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 })
@@ -10,7 +10,7 @@
10
10
  "systemKeys": [
11
11
  "$uuid",
12
12
  "$id",
13
- "$model"
13
+ "$schema"
14
14
  ],
15
15
  "sections": {
16
16
  "info": {
@@ -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` `$model`, then one key per top-level section.
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 Model), so the ref uses the
56
- // entity_ref OPEN form `{ model, entity }` — not a bare uuid (a bare uuid is only
57
- // valid when the field pins a single model). Known uuid → `entry: { model, entity
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
- // TODO: the sync lane is uuid-keyed, so `model` should be the resolved Model UUID;
62
- // it currently carries the Model NAME (e.g. `@std/article`). Wire the name→uuid
63
- // resolution (a registry data-schema read) as a follow-up.
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 = { model: entity.model, entity: entity.uuid }
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: { model, entity }`
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
- $model: FOLDER_MODEL_NAME,
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
- existing[key] = { ...(existing[key] || {}), ...value }
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
- return mergeYamlConfig(queriesYmlPath(siteRoot), queries)
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 finalized collection-record `document` to its source-file shape
328
- * (variant A, via renderEntityDocument) and write it idempotently. The record
329
- * half of the collections lane's write step.
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 - finalized `{ $uuid, $model, <brief>: {…} }`
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: { model, entity: <uuid> }, name: <slug> }`, at the top of the folder
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 `$model` — `records/{schema}/` is where
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 `{ model, entity: <uuid> }`. ⛔ `path_segment` is not
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 `{ model, entity: <uuid> }`; tolerate a bare uuid defensively.
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?, $model, <brief> }`
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 Model's data-schema declaration by name (`$model`).
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 = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
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 declaration = document.$model ? resolveDeclaration(document.$model) : null
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 ${document.$model || '(none)'}` })
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, document.$model, selfScope)
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 ${document.$model} names no pool folder (expected @/name or @org/name)`,
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(document.$model, where.slug)
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
- warnings.push(`${where.slug}: ${err.message}`)
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)