@uniweb/build 0.60.0 → 0.62.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/i18n/freeform.js +3 -1
- package/src/i18n/records.js +161 -80
- package/src/index.js +0 -1
- package/src/resolve-data-schema.js +11 -1
- package/src/runtime-schema.js +14 -16
- package/src/site/queries-config.js +68 -13
- package/src/site/query-processor.js +310 -67
- package/src/uwx/backfill.js +115 -52
- package/src/uwx/data-schema.js +16 -11
- package/src/uwx/project-writer.js +4 -2
- package/src/uwx/record-layout.js +61 -0
- package/src/uwx/records-project.js +23 -18
- package/src/uwx/records.js +370 -201
- package/src/uwx/registry-package.js +3 -3
- package/src/uwx/sync-package.js +66 -4
- package/src/uwx/uuid.js +7 -0
- package/src/validate-data.js +97 -180
package/src/uwx/records.js
CHANGED
|
@@ -3,41 +3,36 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
|
|
5
5
|
// `$id` (the producer-local handle), `$schema` (its data schema, by scoped name), and
|
|
6
|
-
// each
|
|
7
|
-
//
|
|
8
|
-
// returns it in the finalized response; the verb back-fills it into the source
|
|
6
|
+
// each section the record holds, keyed by its name — the brief, the other single
|
|
7
|
+
// sections, and `many` sections as lists of records. The backend MINTS `$uuid` on first
|
|
8
|
+
// sync and returns it in the finalized response; the verb back-fills it into the source
|
|
9
9
|
// file. A record that already carries `$uuid` (a prior back-fill) round-trips it
|
|
10
10
|
// for restore-in-place. No id sidecar — identity is the file-embedded `$uuid` plus
|
|
11
11
|
// the back-fill round-trip.
|
|
12
12
|
//
|
|
13
|
-
// To shape each record, the mapper needs the Model's declaration —
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// To shape each record, the mapper needs the Model's declaration — its sections, their
|
|
14
|
+
// field order, and which fields are localized. The orchestrator reads that from the
|
|
15
|
+
// LOCAL foundation's built `dist/meta/schema.json` (lowered via
|
|
16
|
+
// `toDataSchemaDeclaration`, the same path `uniweb register` uses), so it stays offline,
|
|
17
|
+
// or, for a Model no local foundation defines, through the injected `opts.resolveModel`.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
19
|
+
// ⭐ A RECORD FILE IS READ BY ITS SCHEMA'S LAYOUT (`./record-layout.js`): flat when the
|
|
20
|
+
// schema has one top-level section holding one record, by section otherwise — which is
|
|
21
|
+
// also the only way a `many` section, a self-nesting list (`children:` in the file,
|
|
22
|
+
// `$children` on the wire) or a schema whose root is a list (`@std/nav`) is written.
|
|
23
|
+
// Nested sections are encoded by their own declaration, so a localized field inside one
|
|
24
|
+
// is wrapped per locale like any other.
|
|
22
25
|
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// `
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
// that they are not this shape, and this mapper only knows this shape.
|
|
26
|
+
// ⭐ A REFERENCE (`entity_ref`) NAMES ITS RECORD BY HANDLE in a file — `speaker: ada`,
|
|
27
|
+
// the target's file name or its `slug:` — and is sent as the uuid THIS backend minted for
|
|
28
|
+
// that record (`refResolver`). A target this backend has not minted yet (new in this
|
|
29
|
+
// push) cannot be named on the wire, so its referrer WAITS: sent without that reference
|
|
30
|
+
// and completed by the next pass, or held back when it cannot exist without it — see
|
|
31
|
+
// `sync-package.js`, and the CLI's `pushInPasses`, which repeats the push.
|
|
32
|
+
// ⚠️ item_ref and file fields are still sent as the file writes them.
|
|
31
33
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// ⚠️ Two claims that used to sit here were stale and were removed rather than
|
|
36
|
-
// re-worded, because both named real capabilities as missing: NON-BRIEF single
|
|
37
|
-
// sections are handled (see `recordSections` below — the filter is `multiple !==
|
|
38
|
-
// true`, not `brief === true`), and REMOTE foundations are handled through the
|
|
39
|
-
// injected `opts.resolveModel`. A scope note that under-claims is worse than none:
|
|
40
|
-
// it sends a reader to build what is already there.
|
|
34
|
+
// ⚠️ A record of a `many` section is sent without a `$uuid`, so a push that changes a
|
|
35
|
+
// record replaces that section's records rather than updating them in place.
|
|
41
36
|
|
|
42
37
|
import { readBackendState } from './sync-store.js'
|
|
43
38
|
import { readFileSync, existsSync } from 'node:fs'
|
|
@@ -55,23 +50,26 @@ import {
|
|
|
55
50
|
poolPathReadings,
|
|
56
51
|
poolDirsForSchema,
|
|
57
52
|
} from '../site/entity-pool.js'
|
|
58
|
-
import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField
|
|
53
|
+
import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField } from './data-schema.js'
|
|
54
|
+
import { recordFileLayout, contentBodyTarget } from './record-layout.js'
|
|
59
55
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
60
56
|
import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
61
57
|
import { sha256Hex, toJsonBuffer } from './manifest.js'
|
|
58
|
+
import { isUuid } from './uuid.js'
|
|
62
59
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
63
60
|
import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
|
|
64
|
-
import { localizeScalar, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
61
|
+
import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
65
62
|
import { loadFreeformRecord } from '../i18n/freeform.js'
|
|
66
63
|
import { isDraftRecord } from '../site/record-draft.js'
|
|
67
64
|
|
|
68
65
|
const DATE_KINDS = new Set(['date', 'datetime'])
|
|
69
|
-
//
|
|
70
|
-
// `$body` carries the markdown body (mapped to the
|
|
66
|
+
// The record's OWN keys — identity and transport, never a field of any section, never
|
|
67
|
+
// warned. `$body` carries the markdown body (mapped to the schema's content body field,
|
|
68
|
+
// wherever a single section declares it).
|
|
71
69
|
// Note: there is NO delivery-derived ignore list (route/excerpt/image/content) —
|
|
72
70
|
// the source reader never produces those, and a real unknown key SHOULD warn (it
|
|
73
71
|
// means the frontmatter doesn't match the collection's data schema).
|
|
74
|
-
const
|
|
72
|
+
const RECORD_KEYS = new Set([
|
|
75
73
|
'slug',
|
|
76
74
|
'$id',
|
|
77
75
|
'$uuid',
|
|
@@ -91,8 +89,8 @@ const SKIP_KEYS = new Set([
|
|
|
91
89
|
// they are content rather than identity:
|
|
92
90
|
// - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
|
|
93
91
|
// 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".
|
|
95
|
-
//
|
|
92
|
+
// otherwise a nesting change would be invisible to "send only changed". A record
|
|
93
|
+
// of a self-nesting list (`children:` in its file) carries them too.
|
|
96
94
|
// - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
|
|
97
95
|
// is KEPT so that drafting or un-drafting a record with nothing else changed is
|
|
98
96
|
// still sent; stripped, "send only changed" would never send the toggle.
|
|
@@ -170,6 +168,12 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
170
168
|
return isLocalizedContent(localized) ? localized : { [sourceLocale]: localized }
|
|
171
169
|
}
|
|
172
170
|
if (field.localized) {
|
|
171
|
+
// A list of localized values rides as a list of `{ lang: value }` maps, one per
|
|
172
|
+
// element — the shape page `keywords` already take. ⛔ It went up as a bare list
|
|
173
|
+
// until 2026-09-24: `localizeScalar` passes any object through, arrays included.
|
|
174
|
+
if (Array.isArray(value) && !isMarkupTextField(field)) {
|
|
175
|
+
return localizeScalarList(value, sourceLocale, translations)
|
|
176
|
+
}
|
|
173
177
|
// A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
|
|
174
178
|
// per-locale wholesale (its per-string translations live in the i18n manifest /
|
|
175
179
|
// free-form, not the scalar map). Other localized scalars wrap per-string from
|
|
@@ -195,20 +199,30 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
195
199
|
* no I/O, no minting. The backend mints `$uuid` on first sync; a record that
|
|
196
200
|
* already carries `$uuid` (back-filled from a prior sync) round-trips it.
|
|
197
201
|
*
|
|
202
|
+
* A record is read by its schema's layout (`./record-layout.js`): FLAT when the schema
|
|
203
|
+
* has one top-level section holding one record, its keys that section's fields; BY
|
|
204
|
+
* SECTION otherwise, each top-level section under its own name. Either way the document
|
|
205
|
+
* is the same section-keyed shape, so a pull writes the record back as it was written.
|
|
206
|
+
*
|
|
198
207
|
* @param {object} params
|
|
199
208
|
* @param {string} params.label - what the records are, for messages and the path
|
|
200
209
|
* inside the package — the schema folder they came from (`article`,
|
|
201
210
|
* `std/person`)
|
|
202
|
-
* @param {object[]} params.records - [{ slug, ...
|
|
211
|
+
* @param {object[]} params.records - [{ slug, ...the file's keys }], a
|
|
212
|
+
* markdown body under `$body`
|
|
203
213
|
* @param {object} params.declaration - the `@uniweb/data-schema` declaration
|
|
204
|
-
* (from toDataSchemaDeclaration): `{ name,
|
|
214
|
+
* (from toDataSchemaDeclaration): `{ name, sections }`
|
|
205
215
|
* @param {string} [params.sourceLocale] - locale for localized-field wrap
|
|
206
216
|
* @param {object} [params.translations] - `{ locale: { hash: tgt } }` for wrapping
|
|
207
217
|
* localized scalar fields per-locale (from loadLocaleTranslations)
|
|
218
|
+
* @param {{ resolve: (model: string, value: *) => object }} [params.refs] - what a
|
|
219
|
+
* reference sends (`refResolver`). Without it a reference is sent as written.
|
|
208
220
|
* @returns {{ entities: object[], warnings: string[], refusals: string[] }} each
|
|
209
|
-
* entity is `{ id, uuid, model, file, document }` — `document` is the
|
|
210
|
-
* body
|
|
211
|
-
*
|
|
221
|
+
* entity is `{ id, uuid, model, file, document, pending }` — `document` is the
|
|
222
|
+
* section-keyed body, and `pending` the references it WAITS on, left out of it:
|
|
223
|
+
* `{ path, model, name, required }`, one per reference whose record this backend has
|
|
224
|
+
* not minted yet. `refusals` names the records that cannot be sent as written, one
|
|
225
|
+
* line each; a caller that sends must not send while any is present.
|
|
212
226
|
*/
|
|
213
227
|
export function recordsToEntities({
|
|
214
228
|
label,
|
|
@@ -216,83 +230,29 @@ export function recordsToEntities({
|
|
|
216
230
|
declaration,
|
|
217
231
|
sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale,
|
|
218
232
|
translations,
|
|
233
|
+
refs = null,
|
|
219
234
|
}) {
|
|
220
235
|
if (!declaration || !declaration.name) {
|
|
221
236
|
throw new Error('uwx/records: a declaration with a name is required')
|
|
222
237
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
// types; the markdown body lands in the designated content field WHEREVER it is
|
|
227
|
-
// declared (the brief, or a non-brief body section). `multi` sections (repeating
|
|
228
|
-
// items) can't be expressed by one flat record and are skipped. The brief is the
|
|
229
|
-
// section marked `brief: true` (the sections-tree has no schema-level back-ref).
|
|
230
|
-
const sectionEntries = Object.entries(declaration.sections || {})
|
|
231
|
-
const briefEntry = sectionEntries.find(([, s]) => s && s.brief === true)
|
|
232
|
-
const briefName = briefEntry?.[0]
|
|
233
|
-
if (!briefName) {
|
|
234
|
-
throw new Error(`uwx/records: Model ${declaration.name} has no brief section`)
|
|
235
|
-
}
|
|
236
|
-
// The single sections one record can populate (the brief + sibling singles).
|
|
237
|
-
//
|
|
238
|
-
// ⛔ `fieldByKey` IS NOT A FIELD→SECTION ROUTING TABLE, and must not be used as
|
|
239
|
-
// one. It answers exactly one question — "is this frontmatter key declared
|
|
240
|
-
// anywhere on this Model?" — for the unknown-key warning below. The assignment
|
|
241
|
-
// loop does not consult it: it walks each section and reads `record[key]` afresh.
|
|
242
|
-
//
|
|
243
|
-
// ⚠️ SO A FIELD NAME DECLARED IN TWO SECTIONS FANS OUT. The same frontmatter
|
|
244
|
-
// value is written into BOTH sections, each encoded per its own field's type — so
|
|
245
|
-
// a name shared by, say, a `string` and a `json` field yields one plausible value
|
|
246
|
-
// and one malformed one, silently. And flat frontmatter has no way to give the
|
|
247
|
-
// two fields different values in the first place: the representation is lossy
|
|
248
|
-
// exactly where names collide.
|
|
249
|
-
//
|
|
250
|
-
// ⛔ Nothing prevents this. A previous version of this comment asserted that
|
|
251
|
-
// "field names are unique across a Model's sections (the declaration's own
|
|
252
|
-
// convention)" — that is FALSE, no such convention holds, and nothing validates
|
|
253
|
-
// it: `resolve-data-schema.js` throws in 14 places and never checks this, and the
|
|
254
|
-
// only `unique_field` in the schema translator is a section-scoped constraint on
|
|
255
|
-
// an open map's KEY VALUE, which is unrelated. The invariant was asserted, relied
|
|
256
|
-
// on, and never provided.
|
|
257
|
-
const recordSections = sectionEntries.filter(([, s]) => s && s.multiple !== true)
|
|
258
|
-
const fieldByKey = new Map()
|
|
259
|
-
for (const [, sec] of recordSections) {
|
|
260
|
-
for (const [key, field] of Object.entries(sec.fields || {})) {
|
|
261
|
-
if (!fieldByKey.has(key)) fieldByKey.set(key, field)
|
|
262
|
-
}
|
|
238
|
+
const layout = recordFileLayout(declaration)
|
|
239
|
+
if (layout.sections.length === 0) {
|
|
240
|
+
throw new Error(`uwx/records: ${declaration.name} declares no sections`)
|
|
263
241
|
}
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
|
|
268
|
-
const sectionNames = new Set(sectionEntries.map(([name]) => name))
|
|
269
|
-
const listSections = new Set(
|
|
270
|
-
sectionEntries.filter(([, s]) => s && s.multiple === true).map(([name]) => name)
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
// The markdown body of a `.md` record is the value of the Model's CONTENT body
|
|
274
|
-
// field — a markup `text` field (raw source string) or a `format: prosemirror`
|
|
275
|
-
// json field (docs/reference/entity-content.md) — wherever it is declared (the
|
|
276
|
-
// brief, or a non-brief body section like `article_body.content`). encodeFieldValue
|
|
277
|
-
// does the md→ProseMirror conversion per field kind. One content field is the body
|
|
278
|
-
// target; zero means a `.md` body has nowhere to go (warn per record).
|
|
279
|
-
const contentMatches = []
|
|
280
|
-
for (const [secName, sec] of recordSections) {
|
|
281
|
-
for (const [key, field] of Object.entries(sec.fields || {})) {
|
|
282
|
-
if (isContentBodyField(field)) contentMatches.push({ secName, key })
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
const bodyTarget = contentMatches[0] || null
|
|
242
|
+
// The markdown body of a `.md` record is the value of the schema's CONTENT body field,
|
|
243
|
+
// wherever a top-level single section declares it — the brief, or a body section like
|
|
244
|
+
// `article_body.content`. One field is the target; zero means a body has nowhere to go.
|
|
245
|
+
const { target: bodyTarget, count: bodyFields } = contentBodyTarget(declaration)
|
|
286
246
|
|
|
287
247
|
const entities = []
|
|
288
248
|
const warnings = []
|
|
289
|
-
// Records that cannot be sent as written — see the ⛔
|
|
249
|
+
// Records that cannot be sent as written — see the ⛔ below.
|
|
290
250
|
const refusals = []
|
|
291
|
-
if (
|
|
251
|
+
if (bodyFields > 1) {
|
|
292
252
|
warnings.push(
|
|
293
253
|
`${label}: ${declaration.name} has more than one content ` +
|
|
294
254
|
`(markdown / html / prosemirror) field — the markdown body maps to ` +
|
|
295
|
-
`"${bodyTarget.
|
|
255
|
+
`"${bodyTarget.section}.${bodyTarget.key}"`
|
|
296
256
|
)
|
|
297
257
|
}
|
|
298
258
|
for (const record of records || []) {
|
|
@@ -301,6 +261,7 @@ export function recordsToEntities({
|
|
|
301
261
|
warnings.push(`${label}: a record without a slug was skipped`)
|
|
302
262
|
continue
|
|
303
263
|
}
|
|
264
|
+
const where = `${label}/${slug}`
|
|
304
265
|
// ⛔ `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
|
|
305
266
|
// the @uniweb/folder entity can point a leaf at it via `$ref`. An explicit
|
|
306
267
|
// frontmatter `$id` wins.
|
|
@@ -319,88 +280,65 @@ export function recordsToEntities({
|
|
|
319
280
|
const uuid = record.$uuid || null
|
|
320
281
|
const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
|
|
321
282
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
for (const [key, field] of Object.entries(sec.fields || {})) {
|
|
331
|
-
let value = record[key]
|
|
332
|
-
if (value === undefined && bodyTarget && secName === bodyTarget.secName && key === bodyTarget.key && hasBody) {
|
|
333
|
-
value = record.$body
|
|
334
|
-
}
|
|
335
|
-
if (value === undefined) continue
|
|
336
|
-
const encoded = encodeFieldValue(value, field, sourceLocale, translations)
|
|
337
|
-
if (encoded !== undefined) data[key] = encoded
|
|
283
|
+
const enc = encoder(sourceLocale, translations, refs)
|
|
284
|
+
const sections = layout.flat ? readFlat(layout, record, enc) : readBySection(layout, record, enc)
|
|
285
|
+
// The markdown body fills the content body field, in whatever section declares it,
|
|
286
|
+
// unless the file already sets that field.
|
|
287
|
+
if (hasBody && bodyTarget) {
|
|
288
|
+
const data = (sections[bodyTarget.section] ??= {})
|
|
289
|
+
if (data[bodyTarget.key] == null) {
|
|
290
|
+
data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations)
|
|
338
291
|
}
|
|
339
|
-
if (Object.keys(data).length) sectionData[secName] = data
|
|
340
|
-
}
|
|
341
|
-
// Author keys on no record section (only identity/transport keys in SKIP_KEYS are
|
|
342
|
-
// exempt). A key naming a SECTION is not unknown: the record is written by
|
|
343
|
-
// section, refused below.
|
|
344
|
-
const bySection = []
|
|
345
|
-
const undeclared = []
|
|
346
|
-
for (const key of Object.keys(record)) {
|
|
347
|
-
if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
|
|
348
|
-
if (sectionNames.has(key)) bySection.push(key)
|
|
349
|
-
else undeclared.push(key)
|
|
350
292
|
}
|
|
293
|
+
// The brief is the card, and is always sent — an empty one included, so a required
|
|
294
|
+
// field it lacks is refused here rather than stored empty.
|
|
295
|
+
if (layout.brief && !sections[layout.brief]) sections[layout.brief] = {}
|
|
351
296
|
|
|
352
297
|
// ⛔ A RECORD THE BACKEND WOULD REFUSE — OR WOULD STORE EMPTY — IS REFUSED HERE,
|
|
353
|
-
// before anything is sent
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
// entities with no data behind, so every later push of the site's records was
|
|
366
|
-
// refused. The backend's push has been one transaction since 2026-09-24; a refusal
|
|
367
|
-
// now writes nothing.
|
|
368
|
-
// ⛔ And what a push cannot carry is refused too, because it is lost twice over: the
|
|
369
|
-
// backend never receives it, and the next pull writes the file without it — an
|
|
370
|
-
// undeclared key (`tags:` beside a schema with no `tags`), or a markdown body where
|
|
371
|
-
// the schema has no field to hold it. Both were warnings until 2026-09-24
|
|
372
|
-
// ("not synced"), and a pull then deleted the value from the author's file.
|
|
373
|
-
if (undeclared.length) {
|
|
374
|
-
const one = undeclared.length === 1
|
|
298
|
+
// before anything is sent: a key written where the schema has no place for it
|
|
299
|
+
// (flat in a schema written by section, or declared nowhere), a value of the wrong
|
|
300
|
+
// shape for its section, a markdown body with no field to hold it, and a `required`
|
|
301
|
+
// field the send would lack. Measured 2026-09-23: a record the backend refused
|
|
302
|
+
// left the folder's entries with no data behind them, and every later push of the
|
|
303
|
+
// site's records was refused. The backend's push has been one transaction since
|
|
304
|
+
// 2026-09-24, so a refusal now writes nothing — but what a push cannot carry is
|
|
305
|
+
// still lost twice over: the backend never receives it, and the next pull writes
|
|
306
|
+
// the file without it.
|
|
307
|
+
if (enc.misplaced.length) refusals.push(bySectionRefusal(where, declaration.name, layout, enc.misplaced))
|
|
308
|
+
if (enc.undeclared.length) {
|
|
309
|
+
const one = enc.undeclared.length === 1
|
|
375
310
|
refusals.push(
|
|
376
|
-
`${
|
|
311
|
+
`${where}: ${quoted(enc.undeclared)} ${one ? 'is not a field' : 'are not fields'} of ` +
|
|
377
312
|
`${declaration.name} — a push cannot carry ${one ? 'it' : 'them'}, and the next pull would ` +
|
|
378
313
|
`drop ${one ? 'it' : 'them'} from the file. Declare ${one ? 'it' : 'them'} in the schema, ` +
|
|
379
314
|
`or remove ${one ? 'it' : 'them'}.`
|
|
380
315
|
)
|
|
381
316
|
}
|
|
317
|
+
for (const problem of enc.shape) refusals.push(`${where}: ${problem}`)
|
|
318
|
+
for (const bad of enc.unresolved) refusals.push(`${where}: ${describeUnresolved(bad)}`)
|
|
382
319
|
if (hasBody && !bodyTarget) {
|
|
383
320
|
refusals.push(
|
|
384
|
-
`${
|
|
321
|
+
`${where}: the file has a markdown body, and ${declaration.name} has no field for ` +
|
|
385
322
|
'it (a `markdown` or `richtext` field) — a push cannot carry it. Add one to the schema, ' +
|
|
386
323
|
'or move the text into a field.'
|
|
387
324
|
)
|
|
388
325
|
}
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
const
|
|
326
|
+
if (!enc.misplaced.length && !enc.shape.length) {
|
|
327
|
+
// A required reference that waits for its record is owed, not missing: the push
|
|
328
|
+
// that completes the record sends it (see `pending` below).
|
|
329
|
+
const waiting = new Set(enc.pending.map((p) => `"${p.path}"`))
|
|
330
|
+
const missing = missingRequired(layout, sections).filter((m) => !waiting.has(m))
|
|
393
331
|
if (missing.length) {
|
|
394
332
|
refusals.push(
|
|
395
|
-
`${
|
|
333
|
+
`${where}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
|
|
396
334
|
`has no value for ${missing.length === 1 ? 'it' : 'them'}.`
|
|
397
335
|
)
|
|
398
336
|
}
|
|
399
337
|
}
|
|
400
338
|
|
|
401
339
|
// The `$`-document, in canonical key order: `$uuid?`, `$id`, `$schema`, `$disabled?`,
|
|
402
|
-
// then
|
|
403
|
-
//
|
|
340
|
+
// then the brief (the card) and every other section the record holds, in declared
|
|
341
|
+
// order. `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
|
|
404
342
|
// its side.
|
|
405
343
|
const document = {}
|
|
406
344
|
if (uuid) document.$uuid = uuid
|
|
@@ -410,10 +348,10 @@ export function recordsToEntities({
|
|
|
410
348
|
// publicly delivered. Only `true` travels. An absent key means enabled [Diego,
|
|
411
349
|
// 2026-09-21], so a record whose `draft: true` is removed is sent without the key
|
|
412
350
|
// and is delivered again.
|
|
413
|
-
if (isDraftRecord(record,
|
|
414
|
-
document[
|
|
415
|
-
for (const [
|
|
416
|
-
if (
|
|
351
|
+
if (isDraftRecord(record, where)) document.$disabled = true
|
|
352
|
+
if (layout.brief) document[layout.brief] = sections[layout.brief]
|
|
353
|
+
for (const [name] of layout.sections) {
|
|
354
|
+
if (name !== layout.brief && sections[name] !== undefined) document[name] = sections[name]
|
|
417
355
|
}
|
|
418
356
|
|
|
419
357
|
entities.push({
|
|
@@ -426,11 +364,199 @@ export function recordsToEntities({
|
|
|
426
364
|
// unique within its schema folder, and an authored `$id` need not be.
|
|
427
365
|
file: `entities/${label}/${slug}.json`,
|
|
428
366
|
document,
|
|
367
|
+
pending: enc.pending,
|
|
429
368
|
})
|
|
430
369
|
}
|
|
431
370
|
return { entities, warnings, refusals }
|
|
432
371
|
}
|
|
433
372
|
|
|
373
|
+
// What one record's read collects beside the values: the keys with no place in the
|
|
374
|
+
// schema, the keys written flat in a schema written by section, and values of the
|
|
375
|
+
// wrong shape for their section.
|
|
376
|
+
function encoder(sourceLocale, translations, refs = null) {
|
|
377
|
+
return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], pending: [], unresolved: [] }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// A flat record: its keys, minus the record's own, are the one section's fields.
|
|
381
|
+
function readFlat(layout, record, enc) {
|
|
382
|
+
const [name, def] = layout.sections[0]
|
|
383
|
+
const values = {}
|
|
384
|
+
for (const [key, value] of Object.entries(record)) {
|
|
385
|
+
if (!RECORD_KEYS.has(key)) values[key] = value
|
|
386
|
+
}
|
|
387
|
+
// The record's handle fills a `slug` field the section declares, as the flat form
|
|
388
|
+
// always has (`@std/article`'s brief has one). Written by section, the field is
|
|
389
|
+
// written under its section like any other.
|
|
390
|
+
if (record.slug !== undefined && Object.hasOwn(def.fields || {}, 'slug') && values.slug === undefined) {
|
|
391
|
+
values.slug = record.slug
|
|
392
|
+
}
|
|
393
|
+
return { [name]: encodeRecord(def.fields, values, enc, '', null) }
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// A record written by section: each top-level key names a section of the schema. A key
|
|
397
|
+
// that is instead a FIELD of one is the retired flat form — refused, naming the section
|
|
398
|
+
// it belongs under — and a key that is neither is undeclared.
|
|
399
|
+
function readBySection(layout, record, enc) {
|
|
400
|
+
const byName = new Map(layout.sections)
|
|
401
|
+
const out = {}
|
|
402
|
+
for (const [key, value] of Object.entries(record)) {
|
|
403
|
+
if (RECORD_KEYS.has(key)) continue
|
|
404
|
+
const def = byName.get(key)
|
|
405
|
+
if (def) {
|
|
406
|
+
const encoded = encodeSection(def, value, enc, key)
|
|
407
|
+
if (encoded !== undefined) out[key] = encoded
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
const owners = layout.sections.filter(([, s]) => s.fields && Object.hasOwn(s.fields, key)).map(([n]) => n)
|
|
411
|
+
if (owners.length) enc.misplaced.push({ key, owners })
|
|
412
|
+
else enc.undeclared.push(key)
|
|
413
|
+
}
|
|
414
|
+
return out
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// A section's value: one record for a single section (a binder included — its fields
|
|
418
|
+
// are its child sections), a list of records for a `many` one.
|
|
419
|
+
function encodeSection(def, value, enc, path) {
|
|
420
|
+
if (def.multiple === true) {
|
|
421
|
+
if (!Array.isArray(value)) {
|
|
422
|
+
enc.shape.push(`"${path}" holds a list of records — write it as a list ("- …" under "${path}:").`)
|
|
423
|
+
return undefined
|
|
424
|
+
}
|
|
425
|
+
return value.map((item, i) => encodeItem(def, item, enc, `${path}[${i}]`))
|
|
426
|
+
}
|
|
427
|
+
if (!isPlainObject(value)) {
|
|
428
|
+
enc.shape.push(`"${path}" is a section — write its fields under it ("${path}:" then each field indented).`)
|
|
429
|
+
return undefined
|
|
430
|
+
}
|
|
431
|
+
return encodeRecord(def.fields, value, enc, `${path}.`, null)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// One record of a `many` section. A self-nesting section's records nest under the
|
|
435
|
+
// reserved `children:` key in a file (as `@std/nav` documents it) and under
|
|
436
|
+
// `$children` on the wire, where the backend derives each one's parent.
|
|
437
|
+
function encodeItem(def, item, enc, path) {
|
|
438
|
+
if (!isPlainObject(item)) {
|
|
439
|
+
enc.shape.push(`"${path}" is one record of a list — write it as a map of its fields.`)
|
|
440
|
+
return {}
|
|
441
|
+
}
|
|
442
|
+
const nests = def.self_nesting === true
|
|
443
|
+
const out = encodeRecord(def.fields, item, enc, `${path}.`, nests ? 'children' : null)
|
|
444
|
+
if (nests && item.children !== undefined) {
|
|
445
|
+
if (!Array.isArray(item.children)) {
|
|
446
|
+
enc.shape.push(`"${path}.children" nests records under this one — write it as a list.`)
|
|
447
|
+
} else {
|
|
448
|
+
out.$children = item.children.map((child, j) => encodeItem(def, child, enc, `${path}.children[${j}]`))
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return out
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Encode one record's fields in declared order — a nested section by its own
|
|
455
|
+
// declaration, a leaf by its kind. A key the fields do not declare is recorded as
|
|
456
|
+
// undeclared; `$`-keys (identity a pull may have left on a record) are not content, and
|
|
457
|
+
// `reserved` is the one extra key a caller allows (`children`, on a self-nesting list).
|
|
458
|
+
function encodeRecord(fields, value, enc, prefix, reserved) {
|
|
459
|
+
const out = {}
|
|
460
|
+
for (const [key, field] of Object.entries(fields || {})) {
|
|
461
|
+
const v = value[key]
|
|
462
|
+
if (v === undefined) continue
|
|
463
|
+
const encoded =
|
|
464
|
+
field?.type === 'section'
|
|
465
|
+
? encodeSection(field, v, enc, `${prefix}${key}`)
|
|
466
|
+
: field?.type === 'entity_ref'
|
|
467
|
+
? encodeReference(v, field, enc, `${prefix}${key}`)
|
|
468
|
+
: encodeFieldValue(v, field, enc.sourceLocale, enc.translations)
|
|
469
|
+
if (encoded !== undefined) out[key] = encoded
|
|
470
|
+
}
|
|
471
|
+
for (const key of Object.keys(value)) {
|
|
472
|
+
if (key === reserved || key.startsWith('$') || Object.hasOwn(fields || {}, key)) continue
|
|
473
|
+
enc.undeclared.push(`${prefix}${key}`)
|
|
474
|
+
}
|
|
475
|
+
return out
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
479
|
+
|
|
480
|
+
// A reference, as a push sends it: the uuid this backend minted for the record the file
|
|
481
|
+
// names (`refResolver`). A list of them is each of those. One whose record this backend
|
|
482
|
+
// has not minted yet is left out and noted as WAITING (`enc.pending`) — the push that
|
|
483
|
+
// completes it sends it. Without a resolver, and for a reference open to several Models
|
|
484
|
+
// (which our schemas do not author), the value is sent as written.
|
|
485
|
+
function encodeReference(value, field, enc, path) {
|
|
486
|
+
if (!enc.refs || typeof field.model !== 'string') return value
|
|
487
|
+
if (field.multiple === true) {
|
|
488
|
+
if (!Array.isArray(value)) {
|
|
489
|
+
enc.shape.push(`"${path}" is a list of references — write it as a list of record names.`)
|
|
490
|
+
return undefined
|
|
491
|
+
}
|
|
492
|
+
const sent = []
|
|
493
|
+
const waiting = []
|
|
494
|
+
value.forEach((one, i) => {
|
|
495
|
+
const uuid = resolveReference(one, field, enc, `${path}[${i}]`, waiting)
|
|
496
|
+
if (uuid !== undefined) sent.push(uuid)
|
|
497
|
+
})
|
|
498
|
+
// A required list with nothing to send yet waits whole; otherwise what is known goes now.
|
|
499
|
+
const required = field.required === true && sent.length === 0
|
|
500
|
+
for (const w of waiting) enc.pending.push({ ...w, required })
|
|
501
|
+
return sent
|
|
502
|
+
}
|
|
503
|
+
const waiting = []
|
|
504
|
+
const uuid = resolveReference(value, field, enc, path, waiting)
|
|
505
|
+
for (const w of waiting) enc.pending.push({ ...w, required: field.required === true })
|
|
506
|
+
return uuid
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function resolveReference(value, field, enc, path, waiting) {
|
|
510
|
+
const answer = enc.refs.resolve(field.model, value)
|
|
511
|
+
if (answer.uuid) return answer.uuid
|
|
512
|
+
if (answer.pending) waiting.push({ path, model: field.model, name: value })
|
|
513
|
+
else enc.unresolved.push({ path, model: field.model, value, ...answer })
|
|
514
|
+
return undefined
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Why a reference names no record the push can send.
|
|
518
|
+
function describeUnresolved({ path, model, value, invalid, ambiguous }) {
|
|
519
|
+
if (invalid) return `"${path}" is a reference — write the name of the ${model} record it points at.`
|
|
520
|
+
if (ambiguous) return `"${path}" names "${value}", and more than one record of ${model} is called that — give them distinct names.`
|
|
521
|
+
return `"${path}" names "${value}", and no record of ${model} is called that — write the name of one, or remove the reference.`
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Which record a reference names, and what a push sends for it: the uuid THIS backend
|
|
525
|
+
// minted for that record. A record is named by its handle — its file's name, or its
|
|
526
|
+
// `slug:` — among the records of its Model. A uuid is taken as written too: the record's
|
|
527
|
+
// own id (mapped like a handle's), or the backend's id of a record the project does not
|
|
528
|
+
// hold, which is what a pull writes for one.
|
|
529
|
+
//
|
|
530
|
+
// ⛔ A record this backend has not minted yet — never pushed there — answers `pending`: a
|
|
531
|
+
// uuid it did not mint is never sent (see `buildRecordEntities`), so its referrer waits.
|
|
532
|
+
const AMBIGUOUS = Symbol('ambiguous')
|
|
533
|
+
function refResolver(readSchemas, recordMap) {
|
|
534
|
+
const byModel = new Map() // Model → handle → the record's own id (null before its first push)
|
|
535
|
+
const ownIds = new Set()
|
|
536
|
+
for (const { declaration, flat } of readSchemas) {
|
|
537
|
+
let names = byModel.get(declaration.name)
|
|
538
|
+
if (!names) byModel.set(declaration.name, (names = new Map()))
|
|
539
|
+
for (const rec of flat) {
|
|
540
|
+
const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
|
|
541
|
+
names.set(rec.slug, names.has(rec.slug) ? AMBIGUOUS : own)
|
|
542
|
+
if (own) ownIds.add(own)
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
const minted = (own) => (own && recordMap[own] ? { uuid: recordMap[own] } : { pending: true })
|
|
546
|
+
return {
|
|
547
|
+
resolve(model, value) {
|
|
548
|
+
if (typeof value !== 'string' || value === '') return { invalid: true }
|
|
549
|
+
const names = byModel.get(model)
|
|
550
|
+
if (names?.has(value)) {
|
|
551
|
+
const own = names.get(value)
|
|
552
|
+
return own === AMBIGUOUS ? { ambiguous: true } : minted(own)
|
|
553
|
+
}
|
|
554
|
+
if (isUuid(value)) return ownIds.has(value) ? minted(value) : { uuid: value }
|
|
555
|
+
return { missing: true }
|
|
556
|
+
},
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
434
560
|
// "a", "a and b", "a, b and c" — items are already formatted.
|
|
435
561
|
function listOf(items) {
|
|
436
562
|
if (items.length <= 1) return items.join('')
|
|
@@ -439,42 +565,75 @@ function listOf(items) {
|
|
|
439
565
|
|
|
440
566
|
const quoted = (keys) => listOf(keys.map((k) => `"${k}"`))
|
|
441
567
|
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
568
|
+
// A schema written by section — more than one section, or a list at its root — takes no
|
|
569
|
+
// field at the top of the file: say where each one written there belongs. A field that
|
|
570
|
+
// single sections declare belongs under one of them (named with every candidate, since
|
|
571
|
+
// sections are namespaces and only the author knows which one they meant); a field only
|
|
572
|
+
// a list declares goes into one of the list's records.
|
|
573
|
+
function bySectionRefusal(where, modelName, layout, misplaced) {
|
|
574
|
+
const multiple = new Map(layout.sections.map(([n, s]) => [n, s.multiple === true]))
|
|
575
|
+
const moves = []
|
|
576
|
+
const bySection = new Map()
|
|
577
|
+
for (const { key, owners } of misplaced) {
|
|
578
|
+
const singles = owners.filter((o) => !multiple.get(o))
|
|
579
|
+
if (singles.length === 1) {
|
|
580
|
+
if (!bySection.has(singles[0])) bySection.set(singles[0], [])
|
|
581
|
+
bySection.get(singles[0]).push(key)
|
|
582
|
+
} else if (singles.length > 1) {
|
|
583
|
+
moves.push(`"${key}" under ${listOf(singles.map((o) => `"${o}:"`)).replace(/ and ([^ ]+)$/, ' or $1')}`)
|
|
584
|
+
} else {
|
|
585
|
+
moves.push(`"${key}" into a record of ${listOf(owners.map((o) => `"${o}:"`)).replace(/ and ([^ ]+)$/, ' or $1')}`)
|
|
586
|
+
}
|
|
458
587
|
}
|
|
459
|
-
|
|
588
|
+
const grouped = [...bySection].map(([section, keys]) => `${quoted(keys)} under "${section}:"`)
|
|
589
|
+
const names = layout.sections.map(([n]) => n)
|
|
590
|
+
return (
|
|
591
|
+
`${where}: ${modelName} is written by section, each section under its own name ` +
|
|
592
|
+
`(${quoted(names)}) — move ${listOf([...grouped, ...moves])}.`
|
|
593
|
+
)
|
|
460
594
|
}
|
|
461
595
|
|
|
462
|
-
// The `required` fields
|
|
463
|
-
// single section
|
|
464
|
-
//
|
|
465
|
-
|
|
596
|
+
// The `required` fields the send would lack — checked in every record the send holds:
|
|
597
|
+
// the brief (always sent), each other single section the record fills, each record of a
|
|
598
|
+
// list, and the records nested in them. Each is named by its path in the file —
|
|
599
|
+
// `"title"` in a flat record, `"details.title"` or `"sessions[0].title"` in one written
|
|
600
|
+
// by section. A field counts as present when the send carries a value for it (`null`
|
|
601
|
+
// is none).
|
|
602
|
+
function missingRequired(layout, sections) {
|
|
466
603
|
const missing = []
|
|
467
|
-
for (const [
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
604
|
+
for (const [name, def] of layout.sections) {
|
|
605
|
+
const value = sections[name]
|
|
606
|
+
if (value === undefined) continue
|
|
607
|
+
const base = layout.flat ? '' : name
|
|
608
|
+
if (def.multiple === true) {
|
|
609
|
+
value.forEach((item, i) => missingIn(def, item, `${base}[${i}]`, missing))
|
|
610
|
+
} else {
|
|
611
|
+
missingIn(def, value, base, missing)
|
|
473
612
|
}
|
|
474
613
|
}
|
|
475
614
|
return missing
|
|
476
615
|
}
|
|
477
616
|
|
|
617
|
+
// `def` is the section (or nested section field) `record` is one record of.
|
|
618
|
+
function missingIn(def, record, path, missing) {
|
|
619
|
+
if (!isPlainObject(record)) return
|
|
620
|
+
const at = (key) => (path ? `${path}.${key}` : key)
|
|
621
|
+
for (const [key, field] of Object.entries(def.fields || {})) {
|
|
622
|
+
const value = record[key]
|
|
623
|
+
if (field?.type === 'section') {
|
|
624
|
+
if (field.multiple === true && Array.isArray(value)) {
|
|
625
|
+
value.forEach((item, i) => missingIn(field, item, `${at(key)}[${i}]`, missing))
|
|
626
|
+
} else if (field.multiple !== true && isPlainObject(value)) {
|
|
627
|
+
missingIn(field, value, at(key), missing)
|
|
628
|
+
}
|
|
629
|
+
continue
|
|
630
|
+
}
|
|
631
|
+
if (field?.required === true && value == null) missing.push(`"${at(key)}"`)
|
|
632
|
+
}
|
|
633
|
+
if (def.self_nesting === true && Array.isArray(record.$children)) {
|
|
634
|
+
record.$children.forEach((child, j) => missingIn(def, child, `${path}.children[${j}]`, missing))
|
|
635
|
+
}
|
|
636
|
+
}
|
|
478
637
|
// Post-pass: override a collection record's localized CONTENT body with a per-locale
|
|
479
638
|
// FREE-FORM body when `locales/freeform/{locale}/records/<schema>/<slug>.md` exists
|
|
480
639
|
// — the override wins over the structural map, exactly like site-content sections
|
|
@@ -488,15 +647,16 @@ async function applyFreeformRecordOverrides({
|
|
|
488
647
|
targetLocales,
|
|
489
648
|
localesBase,
|
|
490
649
|
}) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
const
|
|
496
|
-
if (!
|
|
650
|
+
// The body target for a free-form override is the schema's CONTENT body field, wherever
|
|
651
|
+
// a single section declares it — only a localized prosemirror one can take a per-locale
|
|
652
|
+
// doc. ⛔ It looked in the brief alone until 2026-09-24, so `@std/article`'s
|
|
653
|
+
// `article_body.content` never took its translation.
|
|
654
|
+
const { target } = contentBodyTarget(declaration)
|
|
655
|
+
if (!target || !isProseMirrorField(target.field) || !target.field.localized) return
|
|
656
|
+
const { section: sectionName, key: contentKey } = target
|
|
497
657
|
|
|
498
658
|
for (const entity of entities) {
|
|
499
|
-
const data = entity.document?.[
|
|
659
|
+
const data = entity.document?.[sectionName]
|
|
500
660
|
if (!data || data[contentKey] === undefined) continue
|
|
501
661
|
let localized = data[contentKey]
|
|
502
662
|
// The source-locale doc to promote to the localized-map form (when the field is
|
|
@@ -846,6 +1006,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
846
1006
|
// within one submission.
|
|
847
1007
|
const seen = new Set()
|
|
848
1008
|
|
|
1009
|
+
// ⭐ EVERY MODEL'S RECORDS ARE READ BEFORE ANY IS MAPPED: a reference names a record
|
|
1010
|
+
// of another Model by its handle (`speaker: ada`), and the push sends that record's
|
|
1011
|
+
// uuid in its place (`refResolver`).
|
|
1012
|
+
const readSchemas = []
|
|
849
1013
|
for (const [schema, poolEntities] of poolBySchema) {
|
|
850
1014
|
const label = poolEntities[0].dirs.join('/')
|
|
851
1015
|
const modelName = modelFor(schema, `${pool.dir}/${label}/`)
|
|
@@ -891,7 +1055,11 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
891
1055
|
}
|
|
892
1056
|
producedBy.set(pooled.id, produced)
|
|
893
1057
|
}
|
|
1058
|
+
readSchemas.push({ label, declaration, flat, sourceBySlug })
|
|
1059
|
+
}
|
|
1060
|
+
const refs = refResolver(readSchemas, recordMap)
|
|
894
1061
|
|
|
1062
|
+
for (const { label, declaration, flat, sourceBySlug } of readSchemas) {
|
|
895
1063
|
const ownIds = new Map()
|
|
896
1064
|
const onWire = flat.map((rec) => {
|
|
897
1065
|
const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
|
|
@@ -907,6 +1075,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
907
1075
|
declaration,
|
|
908
1076
|
sourceLocale,
|
|
909
1077
|
translations,
|
|
1078
|
+
refs,
|
|
910
1079
|
})
|
|
911
1080
|
// Free-form per-locale body overrides (a full localized doc beats the structural
|
|
912
1081
|
// map) — only meaningful for a multi-locale site with a prosemirror content field.
|