@uniweb/build 0.61.0 → 0.63.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/i18n/freeform.js +3 -1
- package/src/i18n/records.js +161 -80
- 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/folder.js +15 -8
- 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 +387 -212
- package/src/uwx/registry-package.js +53 -4
- package/src/uwx/sync-package.js +18 -2
- package/src/uwx/uuid.js +7 -0
- package/src/validate-data.js +57 -14
package/src/uwx/records.js
CHANGED
|
@@ -3,41 +3,40 @@
|
|
|
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
|
-
//
|
|
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 is new, so this
|
|
29
|
+
// push creates it, and the reference names it by its `$id` in the package —
|
|
30
|
+
// `speaker: { $ref: "speaker/ada" }` — which the backend resolves to the uuid it mints.
|
|
31
|
+
// One push creates both. ⛔ Until 2026-09-25 such a referrer WAITED, sent without the
|
|
32
|
+
// reference or held back, and the CLI pushed again to complete it.
|
|
33
|
+
// ⚠️ item_ref and file fields are still sent as the file writes them.
|
|
31
34
|
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
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.
|
|
35
|
+
// ⚠️ A record of a `many` section is sent without a `$uuid`, so a push that changes a
|
|
36
|
+
// record whose section the backend already holds is REFUSED, whole (`identity_required`):
|
|
37
|
+
// applied, it would re-identify every stored item. Measured 2026-09-25 — this said such a
|
|
38
|
+
// push "replaces that section's records" until then. Its first push, which creates the
|
|
39
|
+
// items, is not affected.
|
|
41
40
|
|
|
42
41
|
import { readBackendState } from './sync-store.js'
|
|
43
42
|
import { readFileSync, existsSync } from 'node:fs'
|
|
@@ -55,23 +54,26 @@ import {
|
|
|
55
54
|
poolPathReadings,
|
|
56
55
|
poolDirsForSchema,
|
|
57
56
|
} from '../site/entity-pool.js'
|
|
58
|
-
import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField
|
|
57
|
+
import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField } from './data-schema.js'
|
|
58
|
+
import { recordFileLayout, contentBodyTarget } from './record-layout.js'
|
|
59
59
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
60
60
|
import { resolveSelfScope, siteSelfScope, refuseOrgOption } from './self-scope.js'
|
|
61
61
|
import { sha256Hex, toJsonBuffer } from './manifest.js'
|
|
62
|
+
import { isUuid } from './uuid.js'
|
|
62
63
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
63
64
|
import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
|
|
64
|
-
import { localizeScalar, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
65
|
+
import { localizeScalar, localizeScalarList, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
65
66
|
import { loadFreeformRecord } from '../i18n/freeform.js'
|
|
66
67
|
import { isDraftRecord } from '../site/record-draft.js'
|
|
67
68
|
|
|
68
69
|
const DATE_KINDS = new Set(['date', 'datetime'])
|
|
69
|
-
//
|
|
70
|
-
// `$body` carries the markdown body (mapped to the
|
|
70
|
+
// The record's OWN keys — identity and transport, never a field of any section, never
|
|
71
|
+
// warned. `$body` carries the markdown body (mapped to the schema's content body field,
|
|
72
|
+
// wherever a single section declares it).
|
|
71
73
|
// Note: there is NO delivery-derived ignore list (route/excerpt/image/content) —
|
|
72
74
|
// the source reader never produces those, and a real unknown key SHOULD warn (it
|
|
73
75
|
// means the frontmatter doesn't match the collection's data schema).
|
|
74
|
-
const
|
|
76
|
+
const RECORD_KEYS = new Set([
|
|
75
77
|
'slug',
|
|
76
78
|
'$id',
|
|
77
79
|
'$uuid',
|
|
@@ -87,24 +89,30 @@ const SKIP_KEYS = new Set([
|
|
|
87
89
|
|
|
88
90
|
// Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$schema`/… — never
|
|
89
91
|
// field data; the sigil-exclusivity invariant guarantees this) at every level,
|
|
90
|
-
// so a back-filled `$uuid` doesn't change the hash.
|
|
92
|
+
// so a back-filled `$uuid` doesn't change the hash. Three are exceptions, because
|
|
91
93
|
// they are content rather than identity:
|
|
92
94
|
// - `$children` is STRUCTURAL content (a self-nesting record's subtree, e.g.
|
|
93
95
|
// 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
|
-
//
|
|
96
|
+
// otherwise a nesting change would be invisible to "send only changed". A record
|
|
97
|
+
// of a self-nesting list (`children:` in its file) carries them too.
|
|
96
98
|
// - `$disabled` is a record's delivery STATE (`draft: true` on the file side). It
|
|
97
99
|
// is KEPT so that drafting or un-drafting a record with nothing else changed is
|
|
98
100
|
// still sent; stripped, "send only changed" would never send the toggle.
|
|
101
|
+
// - `$ref` is a REFERENCE to a record the same push creates (`speaker: { $ref:
|
|
102
|
+
// "speaker/ada" }`), and which record it names is content. Once the backend has
|
|
103
|
+
// minted that record the next push names it by uuid instead — so the CLI re-banks a
|
|
104
|
+
// record that sent a `$ref` after its push (`namesNew`, `sync-package.js`). The
|
|
105
|
+
// folder's ref leaf is not this case: see below.
|
|
99
106
|
function stripSigils(value) {
|
|
100
107
|
if (Array.isArray(value)) return value.map(stripSigils)
|
|
101
108
|
if (value && typeof value === 'object') {
|
|
102
109
|
// ⛔ A `@uniweb/folder` REF LEAF ENCODES ONE REFERENCE TWO WAYS, and hashing the
|
|
103
110
|
// encoding rather than the reference made the folder's hash unreproducible.
|
|
104
111
|
//
|
|
105
|
-
// `refLeaf` (uwx/folder.js) emits
|
|
106
|
-
// `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema,
|
|
107
|
-
// <uuid> }` once it has been minted.
|
|
112
|
+
// `refLeaf` (uwx/folder.js) emits `entry: { $ref: <the record's $id> }` — the pool
|
|
113
|
+
// position `<dirs>/<slug>` — while the record is brand-new, and `entry: { schema,
|
|
114
|
+
// entity: <uuid> }` once it has been minted. (`$ref` was a key of the leaf itself
|
|
115
|
+
// until 2026-09-25, when the backend made it a reference's value.)
|
|
108
116
|
// Both denote the same record. A push hashes the folder BEFORE submitting, then
|
|
109
117
|
// back-fills the minted `$uuid` into every record's source file — so the very
|
|
110
118
|
// next emit builds the OTHER encoding, and the hash the push just banked can
|
|
@@ -118,19 +126,20 @@ function stripSigils(value) {
|
|
|
118
126
|
// `name` (the record's handle) at its position inside the branch that holds it.
|
|
119
127
|
// Two siblings may share a name — records of different schemas can — and position
|
|
120
128
|
// still tells them apart; a leaf that comes to reference another record arrives
|
|
121
|
-
// with that record changed, which re-sends the folder anyway.
|
|
122
|
-
// payload-local handle
|
|
123
|
-
//
|
|
129
|
+
// with that record changed, which re-sends the folder anyway. `entry` carries a
|
|
130
|
+
// payload-local handle or an identity, and both are exactly what `$uuid` is
|
|
131
|
+
// stripped for.
|
|
124
132
|
//
|
|
125
133
|
// ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
|
|
126
134
|
// is: point a leaf at a different record and its `name` moves with it.
|
|
127
135
|
const isFolderRefLeaf = value.kind === 'ref'
|
|
128
136
|
const out = {}
|
|
129
137
|
for (const [k, v] of Object.entries(value)) {
|
|
130
|
-
if (isFolderRefLeaf &&
|
|
131
|
-
// `$children` (a self-nesting subtree)
|
|
132
|
-
//
|
|
133
|
-
|
|
138
|
+
if (isFolderRefLeaf && k === 'entry') continue
|
|
139
|
+
// `$children` (a self-nesting subtree), `$disabled` (a delivery state) and `$ref`
|
|
140
|
+
// (a reference to a record of the same push) are CONTENT, not identity sigils —
|
|
141
|
+
// kept, so a change to any of them stays visible.
|
|
142
|
+
if (k === '$children' || k === '$disabled' || k === '$ref') {
|
|
134
143
|
out[k] = stripSigils(v)
|
|
135
144
|
continue
|
|
136
145
|
}
|
|
@@ -170,6 +179,12 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
170
179
|
return isLocalizedContent(localized) ? localized : { [sourceLocale]: localized }
|
|
171
180
|
}
|
|
172
181
|
if (field.localized) {
|
|
182
|
+
// A list of localized values rides as a list of `{ lang: value }` maps, one per
|
|
183
|
+
// element — the shape page `keywords` already take. ⛔ It went up as a bare list
|
|
184
|
+
// until 2026-09-24: `localizeScalar` passes any object through, arrays included.
|
|
185
|
+
if (Array.isArray(value) && !isMarkupTextField(field)) {
|
|
186
|
+
return localizeScalarList(value, sourceLocale, translations)
|
|
187
|
+
}
|
|
173
188
|
// A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
|
|
174
189
|
// per-locale wholesale (its per-string translations live in the i18n manifest /
|
|
175
190
|
// free-form, not the scalar map). Other localized scalars wrap per-string from
|
|
@@ -195,20 +210,29 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
195
210
|
* no I/O, no minting. The backend mints `$uuid` on first sync; a record that
|
|
196
211
|
* already carries `$uuid` (back-filled from a prior sync) round-trips it.
|
|
197
212
|
*
|
|
213
|
+
* A record is read by its schema's layout (`./record-layout.js`): FLAT when the schema
|
|
214
|
+
* has one top-level section holding one record, its keys that section's fields; BY
|
|
215
|
+
* SECTION otherwise, each top-level section under its own name. Either way the document
|
|
216
|
+
* is the same section-keyed shape, so a pull writes the record back as it was written.
|
|
217
|
+
*
|
|
198
218
|
* @param {object} params
|
|
199
219
|
* @param {string} params.label - what the records are, for messages and the path
|
|
200
220
|
* inside the package — the schema folder they came from (`article`,
|
|
201
221
|
* `std/person`)
|
|
202
|
-
* @param {object[]} params.records - [{ slug, ...
|
|
222
|
+
* @param {object[]} params.records - [{ slug, ...the file's keys }], a
|
|
223
|
+
* markdown body under `$body`
|
|
203
224
|
* @param {object} params.declaration - the `@uniweb/data-schema` declaration
|
|
204
|
-
* (from toDataSchemaDeclaration): `{ name,
|
|
225
|
+
* (from toDataSchemaDeclaration): `{ name, sections }`
|
|
205
226
|
* @param {string} [params.sourceLocale] - locale for localized-field wrap
|
|
206
227
|
* @param {object} [params.translations] - `{ locale: { hash: tgt } }` for wrapping
|
|
207
228
|
* localized scalar fields per-locale (from loadLocaleTranslations)
|
|
229
|
+
* @param {{ resolve: (model: string, value: *) => object }} [params.refs] - what a
|
|
230
|
+
* reference sends (`refResolver`). Without it a reference is sent as written.
|
|
208
231
|
* @returns {{ entities: object[], warnings: string[], refusals: string[] }} each
|
|
209
|
-
* entity is `{ id, uuid, model, file, document }` — `document` is the
|
|
210
|
-
* body
|
|
211
|
-
*
|
|
232
|
+
* entity is `{ id, uuid, slug, model, file, document, namesNew }` — `document` is the
|
|
233
|
+
* section-keyed body, and `namesNew` how many of its references name a record this
|
|
234
|
+
* push creates, by `$ref`. `refusals` names the records that cannot be sent as
|
|
235
|
+
* written, one line each; a caller that sends must not send while any is present.
|
|
212
236
|
*/
|
|
213
237
|
export function recordsToEntities({
|
|
214
238
|
label,
|
|
@@ -216,83 +240,29 @@ export function recordsToEntities({
|
|
|
216
240
|
declaration,
|
|
217
241
|
sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale,
|
|
218
242
|
translations,
|
|
243
|
+
refs = null,
|
|
219
244
|
}) {
|
|
220
245
|
if (!declaration || !declaration.name) {
|
|
221
246
|
throw new Error('uwx/records: a declaration with a name is required')
|
|
222
247
|
}
|
|
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
|
-
}
|
|
248
|
+
const layout = recordFileLayout(declaration)
|
|
249
|
+
if (layout.sections.length === 0) {
|
|
250
|
+
throw new Error(`uwx/records: ${declaration.name} declares no sections`)
|
|
263
251
|
}
|
|
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
|
|
252
|
+
// The markdown body of a `.md` record is the value of the schema's CONTENT body field,
|
|
253
|
+
// wherever a top-level single section declares it — the brief, or a body section like
|
|
254
|
+
// `article_body.content`. One field is the target; zero means a body has nowhere to go.
|
|
255
|
+
const { target: bodyTarget, count: bodyFields } = contentBodyTarget(declaration)
|
|
286
256
|
|
|
287
257
|
const entities = []
|
|
288
258
|
const warnings = []
|
|
289
|
-
// Records that cannot be sent as written — see the ⛔
|
|
259
|
+
// Records that cannot be sent as written — see the ⛔ below.
|
|
290
260
|
const refusals = []
|
|
291
|
-
if (
|
|
261
|
+
if (bodyFields > 1) {
|
|
292
262
|
warnings.push(
|
|
293
263
|
`${label}: ${declaration.name} has more than one content ` +
|
|
294
264
|
`(markdown / html / prosemirror) field — the markdown body maps to ` +
|
|
295
|
-
`"${bodyTarget.
|
|
265
|
+
`"${bodyTarget.section}.${bodyTarget.key}"`
|
|
296
266
|
)
|
|
297
267
|
}
|
|
298
268
|
for (const record of records || []) {
|
|
@@ -301,6 +271,7 @@ export function recordsToEntities({
|
|
|
301
271
|
warnings.push(`${label}: a record without a slug was skipped`)
|
|
302
272
|
continue
|
|
303
273
|
}
|
|
274
|
+
const where = `${label}/${slug}`
|
|
304
275
|
// ⛔ `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
|
|
305
276
|
// the @uniweb/folder entity can point a leaf at it via `$ref`. An explicit
|
|
306
277
|
// frontmatter `$id` wins.
|
|
@@ -319,88 +290,62 @@ export function recordsToEntities({
|
|
|
319
290
|
const uuid = record.$uuid || null
|
|
320
291
|
const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
|
|
321
292
|
|
|
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
|
|
293
|
+
const enc = encoder(sourceLocale, translations, refs)
|
|
294
|
+
const sections = layout.flat ? readFlat(layout, record, enc) : readBySection(layout, record, enc)
|
|
295
|
+
// The markdown body fills the content body field, in whatever section declares it,
|
|
296
|
+
// unless the file already sets that field.
|
|
297
|
+
if (hasBody && bodyTarget) {
|
|
298
|
+
const data = (sections[bodyTarget.section] ??= {})
|
|
299
|
+
if (data[bodyTarget.key] == null) {
|
|
300
|
+
data[bodyTarget.key] = encodeFieldValue(record.$body, bodyTarget.field, sourceLocale, translations)
|
|
338
301
|
}
|
|
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
302
|
}
|
|
303
|
+
// The brief is the card, and is always sent — an empty one included, so a required
|
|
304
|
+
// field it lacks is refused here rather than stored empty.
|
|
305
|
+
if (layout.brief && !sections[layout.brief]) sections[layout.brief] = {}
|
|
351
306
|
|
|
352
307
|
// ⛔ 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
|
|
308
|
+
// before anything is sent: a key written where the schema has no place for it
|
|
309
|
+
// (flat in a schema written by section, or declared nowhere), a value of the wrong
|
|
310
|
+
// shape for its section, a markdown body with no field to hold it, and a `required`
|
|
311
|
+
// field the send would lack. Measured 2026-09-23: a record the backend refused
|
|
312
|
+
// left the folder's entries with no data behind them, and every later push of the
|
|
313
|
+
// site's records was refused. The backend's push has been one transaction since
|
|
314
|
+
// 2026-09-24, so a refusal now writes nothing — but what a push cannot carry is
|
|
315
|
+
// still lost twice over: the backend never receives it, and the next pull writes
|
|
316
|
+
// the file without it.
|
|
317
|
+
if (enc.misplaced.length) refusals.push(bySectionRefusal(where, declaration.name, layout, enc.misplaced))
|
|
318
|
+
if (enc.undeclared.length) {
|
|
319
|
+
const one = enc.undeclared.length === 1
|
|
375
320
|
refusals.push(
|
|
376
|
-
`${
|
|
321
|
+
`${where}: ${quoted(enc.undeclared)} ${one ? 'is not a field' : 'are not fields'} of ` +
|
|
377
322
|
`${declaration.name} — a push cannot carry ${one ? 'it' : 'them'}, and the next pull would ` +
|
|
378
323
|
`drop ${one ? 'it' : 'them'} from the file. Declare ${one ? 'it' : 'them'} in the schema, ` +
|
|
379
324
|
`or remove ${one ? 'it' : 'them'}.`
|
|
380
325
|
)
|
|
381
326
|
}
|
|
327
|
+
for (const problem of enc.shape) refusals.push(`${where}: ${problem}`)
|
|
328
|
+
for (const bad of enc.unresolved) refusals.push(`${where}: ${describeUnresolved(bad)}`)
|
|
382
329
|
if (hasBody && !bodyTarget) {
|
|
383
330
|
refusals.push(
|
|
384
|
-
`${
|
|
331
|
+
`${where}: the file has a markdown body, and ${declaration.name} has no field for ` +
|
|
385
332
|
'it (a `markdown` or `richtext` field) — a push cannot carry it. Add one to the schema, ' +
|
|
386
333
|
'or move the text into a field.'
|
|
387
334
|
)
|
|
388
335
|
}
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
} else {
|
|
392
|
-
const missing = missingRequired(recordSections, sectionData, briefName)
|
|
336
|
+
if (!enc.misplaced.length && !enc.shape.length) {
|
|
337
|
+
const missing = missingRequired(layout, sections)
|
|
393
338
|
if (missing.length) {
|
|
394
339
|
refusals.push(
|
|
395
|
-
`${
|
|
340
|
+
`${where}: ${declaration.name} requires ${listOf(missing)}, and this record ` +
|
|
396
341
|
`has no value for ${missing.length === 1 ? 'it' : 'them'}.`
|
|
397
342
|
)
|
|
398
343
|
}
|
|
399
344
|
}
|
|
400
345
|
|
|
401
346
|
// The `$`-document, in canonical key order: `$uuid?`, `$id`, `$schema`, `$disabled?`,
|
|
402
|
-
// then
|
|
403
|
-
//
|
|
347
|
+
// then the brief (the card) and every other section the record holds, in declared
|
|
348
|
+
// order. `$owner`/`$unit`/`$meta` are omitted — the backend binds owner + unit on
|
|
404
349
|
// its side.
|
|
405
350
|
const document = {}
|
|
406
351
|
if (uuid) document.$uuid = uuid
|
|
@@ -410,10 +355,10 @@ export function recordsToEntities({
|
|
|
410
355
|
// publicly delivered. Only `true` travels. An absent key means enabled [Diego,
|
|
411
356
|
// 2026-09-21], so a record whose `draft: true` is removed is sent without the key
|
|
412
357
|
// and is delivered again.
|
|
413
|
-
if (isDraftRecord(record,
|
|
414
|
-
document[
|
|
415
|
-
for (const [
|
|
416
|
-
if (
|
|
358
|
+
if (isDraftRecord(record, where)) document.$disabled = true
|
|
359
|
+
if (layout.brief) document[layout.brief] = sections[layout.brief]
|
|
360
|
+
for (const [name] of layout.sections) {
|
|
361
|
+
if (name !== layout.brief && sections[name] !== undefined) document[name] = sections[name]
|
|
417
362
|
}
|
|
418
363
|
|
|
419
364
|
entities.push({
|
|
@@ -426,11 +371,198 @@ export function recordsToEntities({
|
|
|
426
371
|
// unique within its schema folder, and an authored `$id` need not be.
|
|
427
372
|
file: `entities/${label}/${slug}.json`,
|
|
428
373
|
document,
|
|
374
|
+
namesNew: enc.namesNew,
|
|
429
375
|
})
|
|
430
376
|
}
|
|
431
377
|
return { entities, warnings, refusals }
|
|
432
378
|
}
|
|
433
379
|
|
|
380
|
+
// What one record's read collects beside the values: the keys with no place in the
|
|
381
|
+
// schema, the keys written flat in a schema written by section, and values of the
|
|
382
|
+
// wrong shape for their section.
|
|
383
|
+
function encoder(sourceLocale, translations, refs = null) {
|
|
384
|
+
return { sourceLocale, translations, refs, undeclared: [], misplaced: [], shape: [], unresolved: [], namesNew: 0 }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A flat record: its keys, minus the record's own, are the one section's fields.
|
|
388
|
+
function readFlat(layout, record, enc) {
|
|
389
|
+
const [name, def] = layout.sections[0]
|
|
390
|
+
const values = {}
|
|
391
|
+
for (const [key, value] of Object.entries(record)) {
|
|
392
|
+
if (!RECORD_KEYS.has(key)) values[key] = value
|
|
393
|
+
}
|
|
394
|
+
// The record's handle fills a `slug` field the section declares, as the flat form
|
|
395
|
+
// always has (`@std/article`'s brief has one). Written by section, the field is
|
|
396
|
+
// written under its section like any other.
|
|
397
|
+
if (record.slug !== undefined && Object.hasOwn(def.fields || {}, 'slug') && values.slug === undefined) {
|
|
398
|
+
values.slug = record.slug
|
|
399
|
+
}
|
|
400
|
+
return { [name]: encodeRecord(def.fields, values, enc, '', null) }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// A record written by section: each top-level key names a section of the schema. A key
|
|
404
|
+
// that is instead a FIELD of one is the retired flat form — refused, naming the section
|
|
405
|
+
// it belongs under — and a key that is neither is undeclared.
|
|
406
|
+
function readBySection(layout, record, enc) {
|
|
407
|
+
const byName = new Map(layout.sections)
|
|
408
|
+
const out = {}
|
|
409
|
+
for (const [key, value] of Object.entries(record)) {
|
|
410
|
+
if (RECORD_KEYS.has(key)) continue
|
|
411
|
+
const def = byName.get(key)
|
|
412
|
+
if (def) {
|
|
413
|
+
const encoded = encodeSection(def, value, enc, key)
|
|
414
|
+
if (encoded !== undefined) out[key] = encoded
|
|
415
|
+
continue
|
|
416
|
+
}
|
|
417
|
+
const owners = layout.sections.filter(([, s]) => s.fields && Object.hasOwn(s.fields, key)).map(([n]) => n)
|
|
418
|
+
if (owners.length) enc.misplaced.push({ key, owners })
|
|
419
|
+
else enc.undeclared.push(key)
|
|
420
|
+
}
|
|
421
|
+
return out
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// A section's value: one record for a single section (a binder included — its fields
|
|
425
|
+
// are its child sections), a list of records for a `many` one.
|
|
426
|
+
function encodeSection(def, value, enc, path) {
|
|
427
|
+
if (def.multiple === true) {
|
|
428
|
+
if (!Array.isArray(value)) {
|
|
429
|
+
enc.shape.push(`"${path}" holds a list of records — write it as a list ("- …" under "${path}:").`)
|
|
430
|
+
return undefined
|
|
431
|
+
}
|
|
432
|
+
return value.map((item, i) => encodeItem(def, item, enc, `${path}[${i}]`))
|
|
433
|
+
}
|
|
434
|
+
if (!isPlainObject(value)) {
|
|
435
|
+
enc.shape.push(`"${path}" is a section — write its fields under it ("${path}:" then each field indented).`)
|
|
436
|
+
return undefined
|
|
437
|
+
}
|
|
438
|
+
return encodeRecord(def.fields, value, enc, `${path}.`, null)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// One record of a `many` section. A self-nesting section's records nest under the
|
|
442
|
+
// reserved `children:` key in a file (as `@std/nav` documents it) and under
|
|
443
|
+
// `$children` on the wire, where the backend derives each one's parent.
|
|
444
|
+
function encodeItem(def, item, enc, path) {
|
|
445
|
+
if (!isPlainObject(item)) {
|
|
446
|
+
enc.shape.push(`"${path}" is one record of a list — write it as a map of its fields.`)
|
|
447
|
+
return {}
|
|
448
|
+
}
|
|
449
|
+
const nests = def.self_nesting === true
|
|
450
|
+
const out = encodeRecord(def.fields, item, enc, `${path}.`, nests ? 'children' : null)
|
|
451
|
+
if (nests && item.children !== undefined) {
|
|
452
|
+
if (!Array.isArray(item.children)) {
|
|
453
|
+
enc.shape.push(`"${path}.children" nests records under this one — write it as a list.`)
|
|
454
|
+
} else {
|
|
455
|
+
out.$children = item.children.map((child, j) => encodeItem(def, child, enc, `${path}.children[${j}]`))
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return out
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Encode one record's fields in declared order — a nested section by its own
|
|
462
|
+
// declaration, a leaf by its kind. A key the fields do not declare is recorded as
|
|
463
|
+
// undeclared; `$`-keys (identity a pull may have left on a record) are not content, and
|
|
464
|
+
// `reserved` is the one extra key a caller allows (`children`, on a self-nesting list).
|
|
465
|
+
function encodeRecord(fields, value, enc, prefix, reserved) {
|
|
466
|
+
const out = {}
|
|
467
|
+
for (const [key, field] of Object.entries(fields || {})) {
|
|
468
|
+
const v = value[key]
|
|
469
|
+
if (v === undefined) continue
|
|
470
|
+
const encoded =
|
|
471
|
+
field?.type === 'section'
|
|
472
|
+
? encodeSection(field, v, enc, `${prefix}${key}`)
|
|
473
|
+
: field?.type === 'entity_ref'
|
|
474
|
+
? encodeReference(v, field, enc, `${prefix}${key}`)
|
|
475
|
+
: encodeFieldValue(v, field, enc.sourceLocale, enc.translations)
|
|
476
|
+
if (encoded !== undefined) out[key] = encoded
|
|
477
|
+
}
|
|
478
|
+
for (const key of Object.keys(value)) {
|
|
479
|
+
if (key === reserved || key.startsWith('$') || Object.hasOwn(fields || {}, key)) continue
|
|
480
|
+
enc.undeclared.push(`${prefix}${key}`)
|
|
481
|
+
}
|
|
482
|
+
return out
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
486
|
+
|
|
487
|
+
// A reference, as a push sends it: the uuid this backend minted for the record the file
|
|
488
|
+
// names (`refResolver`) — or, for a record it has not minted, which this push creates,
|
|
489
|
+
// `{ $ref: <that record's $id> }`, the backend's name for a record of the same package.
|
|
490
|
+
// A list of them is each of those. Without a resolver, and for a reference open to
|
|
491
|
+
// several Models (which our schemas do not author), the value is sent as written.
|
|
492
|
+
function encodeReference(value, field, enc, path) {
|
|
493
|
+
if (!enc.refs || typeof field.model !== 'string') return value
|
|
494
|
+
if (field.multiple === true) {
|
|
495
|
+
if (!Array.isArray(value)) {
|
|
496
|
+
enc.shape.push(`"${path}" is a list of references — write it as a list of record names.`)
|
|
497
|
+
return undefined
|
|
498
|
+
}
|
|
499
|
+
const sent = []
|
|
500
|
+
value.forEach((one, i) => {
|
|
501
|
+
const ref = resolveReference(one, field, enc, `${path}[${i}]`)
|
|
502
|
+
if (ref !== undefined) sent.push(ref)
|
|
503
|
+
})
|
|
504
|
+
return sent
|
|
505
|
+
}
|
|
506
|
+
return resolveReference(value, field, enc, path)
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function resolveReference(value, field, enc, path) {
|
|
510
|
+
const answer = enc.refs.resolve(field.model, value)
|
|
511
|
+
if (answer.uuid) return answer.uuid
|
|
512
|
+
if (answer.ref) {
|
|
513
|
+
enc.namesNew++
|
|
514
|
+
return { $ref: answer.ref }
|
|
515
|
+
}
|
|
516
|
+
enc.unresolved.push({ path, model: field.model, value, ...answer })
|
|
517
|
+
return undefined
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Why a reference names no record the push can send.
|
|
521
|
+
function describeUnresolved({ path, model, value, invalid, ambiguous }) {
|
|
522
|
+
if (invalid) return `"${path}" is a reference — write the name of the ${model} record it points at.`
|
|
523
|
+
if (ambiguous) return `"${path}" names "${value}", and more than one record of ${model} is called that — give them distinct names.`
|
|
524
|
+
return `"${path}" names "${value}", and no record of ${model} is called that — write the name of one, or remove the reference.`
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Which record a reference names, and what a push sends for it: the uuid THIS backend
|
|
528
|
+
// minted for that record. A record is named by its handle — its file's name, or its
|
|
529
|
+
// `slug:` — among the records of its Model. A uuid is taken as written too: the record's
|
|
530
|
+
// own id (mapped like a handle's), or the backend's id of a record the project does not
|
|
531
|
+
// hold, which is what a pull writes for one.
|
|
532
|
+
//
|
|
533
|
+
// ⭐ A record this backend has not minted yet — never pushed there — answers with its
|
|
534
|
+
// `$id` (`ref`): it is new, so this push creates it, and the backend takes
|
|
535
|
+
// `{ $ref: <$id> }` for a record of the same package. A uuid it did not mint is never
|
|
536
|
+
// sent (see `buildRecordEntities`).
|
|
537
|
+
const AMBIGUOUS = Symbol('ambiguous')
|
|
538
|
+
function refResolver(readSchemas, recordMap) {
|
|
539
|
+
const byModel = new Map() // Model → handle → the record: its own id (null before its first push) and `$id`
|
|
540
|
+
const byOwnId = new Map() // the record's own id → the record
|
|
541
|
+
for (const { declaration, flat } of readSchemas) {
|
|
542
|
+
let names = byModel.get(declaration.name)
|
|
543
|
+
if (!names) byModel.set(declaration.name, (names = new Map()))
|
|
544
|
+
for (const rec of flat) {
|
|
545
|
+
const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
|
|
546
|
+
const record = { own, id: rec.$id }
|
|
547
|
+
names.set(rec.slug, names.has(rec.slug) ? AMBIGUOUS : record)
|
|
548
|
+
if (own) byOwnId.set(own, record)
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
const sent = ({ own, id }) => (own && recordMap[own] ? { uuid: recordMap[own] } : { ref: id })
|
|
552
|
+
return {
|
|
553
|
+
resolve(model, value) {
|
|
554
|
+
if (typeof value !== 'string' || value === '') return { invalid: true }
|
|
555
|
+
const names = byModel.get(model)
|
|
556
|
+
if (names?.has(value)) {
|
|
557
|
+
const record = names.get(value)
|
|
558
|
+
return record === AMBIGUOUS ? { ambiguous: true } : sent(record)
|
|
559
|
+
}
|
|
560
|
+
if (isUuid(value)) return byOwnId.has(value) ? sent(byOwnId.get(value)) : { uuid: value }
|
|
561
|
+
return { missing: true }
|
|
562
|
+
},
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
434
566
|
// "a", "a and b", "a, b and c" — items are already formatted.
|
|
435
567
|
function listOf(items) {
|
|
436
568
|
if (items.length <= 1) return items.join('')
|
|
@@ -439,42 +571,75 @@ function listOf(items) {
|
|
|
439
571
|
|
|
440
572
|
const quoted = (keys) => listOf(keys.map((k) => `"${k}"`))
|
|
441
573
|
|
|
442
|
-
//
|
|
443
|
-
//
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
574
|
+
// A schema written by section — more than one section, or a list at its root — takes no
|
|
575
|
+
// field at the top of the file: say where each one written there belongs. A field that
|
|
576
|
+
// single sections declare belongs under one of them (named with every candidate, since
|
|
577
|
+
// sections are namespaces and only the author knows which one they meant); a field only
|
|
578
|
+
// a list declares goes into one of the list's records.
|
|
579
|
+
function bySectionRefusal(where, modelName, layout, misplaced) {
|
|
580
|
+
const multiple = new Map(layout.sections.map(([n, s]) => [n, s.multiple === true]))
|
|
581
|
+
const moves = []
|
|
582
|
+
const bySection = new Map()
|
|
583
|
+
for (const { key, owners } of misplaced) {
|
|
584
|
+
const singles = owners.filter((o) => !multiple.get(o))
|
|
585
|
+
if (singles.length === 1) {
|
|
586
|
+
if (!bySection.has(singles[0])) bySection.set(singles[0], [])
|
|
587
|
+
bySection.get(singles[0]).push(key)
|
|
588
|
+
} else if (singles.length > 1) {
|
|
589
|
+
moves.push(`"${key}" under ${listOf(singles.map((o) => `"${o}:"`)).replace(/ and ([^ ]+)$/, ' or $1')}`)
|
|
590
|
+
} else {
|
|
591
|
+
moves.push(`"${key}" into a record of ${listOf(owners.map((o) => `"${o}:"`)).replace(/ and ([^ ]+)$/, ' or $1')}`)
|
|
592
|
+
}
|
|
458
593
|
}
|
|
459
|
-
|
|
594
|
+
const grouped = [...bySection].map(([section, keys]) => `${quoted(keys)} under "${section}:"`)
|
|
595
|
+
const names = layout.sections.map(([n]) => n)
|
|
596
|
+
return (
|
|
597
|
+
`${where}: ${modelName} is written by section, each section under its own name ` +
|
|
598
|
+
`(${quoted(names)}) — move ${listOf([...grouped, ...moves])}.`
|
|
599
|
+
)
|
|
460
600
|
}
|
|
461
601
|
|
|
462
|
-
// The `required` fields
|
|
463
|
-
// single section
|
|
464
|
-
//
|
|
465
|
-
|
|
602
|
+
// The `required` fields the send would lack — checked in every record the send holds:
|
|
603
|
+
// the brief (always sent), each other single section the record fills, each record of a
|
|
604
|
+
// list, and the records nested in them. Each is named by its path in the file —
|
|
605
|
+
// `"title"` in a flat record, `"details.title"` or `"sessions[0].title"` in one written
|
|
606
|
+
// by section. A field counts as present when the send carries a value for it (`null`
|
|
607
|
+
// is none).
|
|
608
|
+
function missingRequired(layout, sections) {
|
|
466
609
|
const missing = []
|
|
467
|
-
for (const [
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
610
|
+
for (const [name, def] of layout.sections) {
|
|
611
|
+
const value = sections[name]
|
|
612
|
+
if (value === undefined) continue
|
|
613
|
+
const base = layout.flat ? '' : name
|
|
614
|
+
if (def.multiple === true) {
|
|
615
|
+
value.forEach((item, i) => missingIn(def, item, `${base}[${i}]`, missing))
|
|
616
|
+
} else {
|
|
617
|
+
missingIn(def, value, base, missing)
|
|
473
618
|
}
|
|
474
619
|
}
|
|
475
620
|
return missing
|
|
476
621
|
}
|
|
477
622
|
|
|
623
|
+
// `def` is the section (or nested section field) `record` is one record of.
|
|
624
|
+
function missingIn(def, record, path, missing) {
|
|
625
|
+
if (!isPlainObject(record)) return
|
|
626
|
+
const at = (key) => (path ? `${path}.${key}` : key)
|
|
627
|
+
for (const [key, field] of Object.entries(def.fields || {})) {
|
|
628
|
+
const value = record[key]
|
|
629
|
+
if (field?.type === 'section') {
|
|
630
|
+
if (field.multiple === true && Array.isArray(value)) {
|
|
631
|
+
value.forEach((item, i) => missingIn(field, item, `${at(key)}[${i}]`, missing))
|
|
632
|
+
} else if (field.multiple !== true && isPlainObject(value)) {
|
|
633
|
+
missingIn(field, value, at(key), missing)
|
|
634
|
+
}
|
|
635
|
+
continue
|
|
636
|
+
}
|
|
637
|
+
if (field?.required === true && value == null) missing.push(`"${at(key)}"`)
|
|
638
|
+
}
|
|
639
|
+
if (def.self_nesting === true && Array.isArray(record.$children)) {
|
|
640
|
+
record.$children.forEach((child, j) => missingIn(def, child, `${path}.children[${j}]`, missing))
|
|
641
|
+
}
|
|
642
|
+
}
|
|
478
643
|
// Post-pass: override a collection record's localized CONTENT body with a per-locale
|
|
479
644
|
// FREE-FORM body when `locales/freeform/{locale}/records/<schema>/<slug>.md` exists
|
|
480
645
|
// — the override wins over the structural map, exactly like site-content sections
|
|
@@ -488,15 +653,16 @@ async function applyFreeformRecordOverrides({
|
|
|
488
653
|
targetLocales,
|
|
489
654
|
localesBase,
|
|
490
655
|
}) {
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
//
|
|
495
|
-
const
|
|
496
|
-
if (!
|
|
656
|
+
// The body target for a free-form override is the schema's CONTENT body field, wherever
|
|
657
|
+
// a single section declares it — only a localized prosemirror one can take a per-locale
|
|
658
|
+
// doc. ⛔ It looked in the brief alone until 2026-09-24, so `@std/article`'s
|
|
659
|
+
// `article_body.content` never took its translation.
|
|
660
|
+
const { target } = contentBodyTarget(declaration)
|
|
661
|
+
if (!target || !isProseMirrorField(target.field) || !target.field.localized) return
|
|
662
|
+
const { section: sectionName, key: contentKey } = target
|
|
497
663
|
|
|
498
664
|
for (const entity of entities) {
|
|
499
|
-
const data = entity.document?.[
|
|
665
|
+
const data = entity.document?.[sectionName]
|
|
500
666
|
if (!data || data[contentKey] === undefined) continue
|
|
501
667
|
let localized = data[contentKey]
|
|
502
668
|
// The source-locale doc to promote to the localized-map form (when the field is
|
|
@@ -846,6 +1012,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
846
1012
|
// within one submission.
|
|
847
1013
|
const seen = new Set()
|
|
848
1014
|
|
|
1015
|
+
// ⭐ EVERY MODEL'S RECORDS ARE READ BEFORE ANY IS MAPPED: a reference names a record
|
|
1016
|
+
// of another Model by its handle (`speaker: ada`), and the push sends that record's
|
|
1017
|
+
// uuid in its place — or its `$id`, while it is new (`refResolver`).
|
|
1018
|
+
const readSchemas = []
|
|
849
1019
|
for (const [schema, poolEntities] of poolBySchema) {
|
|
850
1020
|
const label = poolEntities[0].dirs.join('/')
|
|
851
1021
|
const modelName = modelFor(schema, `${pool.dir}/${label}/`)
|
|
@@ -891,7 +1061,11 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
891
1061
|
}
|
|
892
1062
|
producedBy.set(pooled.id, produced)
|
|
893
1063
|
}
|
|
1064
|
+
readSchemas.push({ label, declaration, flat, sourceBySlug })
|
|
1065
|
+
}
|
|
1066
|
+
const refs = refResolver(readSchemas, recordMap)
|
|
894
1067
|
|
|
1068
|
+
for (const { label, declaration, flat, sourceBySlug } of readSchemas) {
|
|
895
1069
|
const ownIds = new Map()
|
|
896
1070
|
const onWire = flat.map((rec) => {
|
|
897
1071
|
const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
|
|
@@ -907,6 +1081,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
907
1081
|
declaration,
|
|
908
1082
|
sourceLocale,
|
|
909
1083
|
translations,
|
|
1084
|
+
refs,
|
|
910
1085
|
})
|
|
911
1086
|
// Free-form per-locale body overrides (a full localized doc beats the structural
|
|
912
1087
|
// map) — only meaningful for a multi-locale site with a prosemirror content field.
|