@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.61.0",
3
+ "version": "0.63.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/semantic-parser": "^1.4.1",
62
- "@uniweb/schemas": "^0.6.0",
63
- "@uniweb/theming": "^0.1.15",
61
+ "@uniweb/content-writer": "^0.3.4",
62
+ "@uniweb/schemas": "^0.7.0",
64
63
  "@uniweb/projections": "^0.7.3",
65
- "@uniweb/content-writer": "^0.3.4"
64
+ "@uniweb/semantic-parser": "^1.4.1",
65
+ "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "@uniweb/runtime": "^0.27.4"
@@ -117,7 +117,7 @@ export async function loadFreeformTranslation(section, page, locale, localesDir)
117
117
  * @param {string} schema - the entity's model ref (`@/name` or `@org/name`)
118
118
  * @param {string} locale - Locale code
119
119
  * @param {string} localesDir - Path to locales directory
120
- * @returns {Promise<Object|null>} Parsed translation { frontmatter, content } or null
120
+ * @returns {Promise<Object|null>} Parsed translation { frontmatter, content, markdown } or null
121
121
  */
122
122
  export async function loadFreeformRecord(item, schema, locale, localesDir) {
123
123
  const slug = item.slug
@@ -142,6 +142,8 @@ export async function loadFreeformRecord(item, schema, locale, localesDir) {
142
142
  return {
143
143
  frontmatter: Object.keys(frontmatter).length > 0 ? frontmatter : null,
144
144
  content: proseMirrorContent,
145
+ // The body's markdown source — what a markup `text` body field holds.
146
+ markdown: proseMirrorContent ? body : null,
145
147
  filePath,
146
148
  relativePath: relative(join(localesDir, 'freeform', locale), filePath)
147
149
  }
@@ -22,7 +22,9 @@ import { loadFreeformRecord } from './freeform.js'
22
22
  // answer for a tagged data block's payload — a `label` is prose and an `href`
23
23
  // is not, wherever the value came from. Moved rather than copied: two tuned
24
24
  // denylists would drift, and drift here is silent.
25
- import { resolveQueriesConfig } from '../site/queries-config.js'
25
+ import { resolveQueriesConfig, resolveRecordSchemas } from '../site/queries-config.js'
26
+ import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
27
+ import { flatFormRefusal } from '../site/query-processor.js'
26
28
  import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
27
29
  import {
28
30
  NON_TRANSLATABLE_TYPES,
@@ -171,6 +173,57 @@ function isRecordSystemField(key, topLevel) {
171
173
  return key.startsWith('$') || (topLevel && (key === 'slug' || key === 'path'))
172
174
  }
173
175
 
176
+ /**
177
+ * A record's handle — `$name`, which the build sets from the record's file (`slug:` or
178
+ * its name). ⭐ Not `slug` alone: a record of a schema whose brief declares a `slug`
179
+ * FIELD (`@std/article`) is delivered with that field at the top, and it need not be
180
+ * the file's name, which is what the free-form tree and a translation's context use.
181
+ */
182
+ function recordHandle(item) {
183
+ return item.$name ?? item.slug ?? item.id ?? item.name ?? 'unknown'
184
+ }
185
+
186
+ /** Is this value a ProseMirror document? */
187
+ function isProseMirrorDoc(value) {
188
+ return !!value && typeof value === 'object' && value.type === 'doc' && Array.isArray(value.content)
189
+ }
190
+
191
+ /**
192
+ * Every ProseMirror document a record holds, with its path. ⭐ A markdown body is
193
+ * delivered in its schema's content body field — `article_body.content` for
194
+ * `@std/article` — and at `content` only for a record with no schema; a rich field can
195
+ * sit anywhere else. ⛔ Until 2026-09-24 only `content` at the top was read, which is
196
+ * where a delivered `@std/article` holds no body.
197
+ *
198
+ * @returns {Array<{ path: string, doc: Object }>}
199
+ */
200
+ function proseMirrorDocs(value, path = '', out = [], depth = 0) {
201
+ if (!value || typeof value !== 'object' || depth > MAX_HEURISTIC_DEPTH) return out
202
+ if (isProseMirrorDoc(value)) {
203
+ out.push({ path, doc: value })
204
+ return out
205
+ }
206
+ const list = Array.isArray(value)
207
+ for (const [key, v] of list ? value.map((x, i) => [i, x]) : Object.entries(value)) {
208
+ if (!list && isRecordSystemField(key, depth === 0)) continue
209
+ const at = list ? `${path}[${key}]` : path ? `${path}.${key}` : key
210
+ proseMirrorDocs(v, at, out, depth + 1)
211
+ }
212
+ return out
213
+ }
214
+
215
+ /** The record with every ProseMirror document it holds translated — the rest copied as is. */
216
+ function translateDocs(value, context, translations, depth = 0) {
217
+ if (!value || typeof value !== 'object' || depth > MAX_HEURISTIC_DEPTH) return value
218
+ if (isProseMirrorDoc(value)) return translateProseMirrorDoc(value, context, translations)
219
+ if (Array.isArray(value)) return value.map((v) => translateDocs(v, context, translations, depth + 1))
220
+ const out = {}
221
+ for (const [key, v] of Object.entries(value)) {
222
+ out[key] = isRecordSystemField(key, depth === 0) ? v : translateDocs(v, context, translations, depth + 1)
223
+ }
224
+ return out
225
+ }
226
+
174
227
  // ---------------------------------------------------------------------------
175
228
  // Schema-guided extraction
176
229
  // ---------------------------------------------------------------------------
@@ -184,15 +237,12 @@ function isRecordSystemField(key, topLevel) {
184
237
  * @param {Object} units - Accumulator
185
238
  */
186
239
  function extractWithSchema(item, schema, recordDir, units) {
187
- const slug = item.slug || item.id || item.name || 'unknown'
188
- const context = { record: `${recordDir}/${slug}` }
240
+ const context = { record: `${recordDir}/${recordHandle(item)}` }
189
241
 
190
242
  extractFromItemWithSchema(item, schema.fields, '', context, units)
191
243
 
192
- // Also extract ProseMirror content body (not covered by schema fields)
193
- if (item.content?.type === 'doc') {
194
- extractFromProseMirrorDoc(item.content, context, units)
195
- }
244
+ // Also extract the record's ProseMirror documents (not covered by schema fields)
245
+ for (const { path, doc } of proseMirrorDocs(item)) extractFromProseMirrorDoc(doc, context, units, path)
196
246
  }
197
247
 
198
248
  /**
@@ -247,15 +297,12 @@ function extractFromItemWithSchema(data, fields, pathPrefix, context, units) {
247
297
  * Recursively walks the data, extracting strings that look like human-readable text.
248
298
  */
249
299
  function extractHeuristic(item, recordDir, units) {
250
- const slug = item.slug || item.id || item.name || 'unknown'
251
- const context = { record: `${recordDir}/${slug}` }
300
+ const context = { record: `${recordDir}/${recordHandle(item)}` }
252
301
 
253
302
  extractFromItemHeuristic(item, '', context, units, 0)
254
303
 
255
- // Also extract ProseMirror content body
256
- if (item.content?.type === 'doc') {
257
- extractFromProseMirrorDoc(item.content, context, units)
258
- }
304
+ // Also extract the record's ProseMirror documents, wherever it holds them
305
+ for (const { path, doc } of proseMirrorDocs(item)) extractFromProseMirrorDoc(doc, context, units, path)
259
306
  }
260
307
 
261
308
  /**
@@ -277,8 +324,8 @@ function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
277
324
  if (value === undefined || value === null) continue
278
325
  if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
279
326
 
280
- // Skip ProseMirror content (handled separately)
281
- if (key === 'content' && typeof value === 'object' && value?.type === 'doc') continue
327
+ // Skip a ProseMirror document (handled separately)
328
+ if (isProseMirrorDoc(value)) continue
282
329
 
283
330
  if (typeof value === 'string') {
284
331
  // Skip known structural field names
@@ -306,15 +353,9 @@ function extractFromItemHeuristic(data, pathPrefix, context, units, depth) {
306
353
  /**
307
354
  * Translate item fields using schema guidance.
308
355
  */
309
- function translateWithSchema(item, schema, context, translations, includeContent = true) {
310
- const translated = { ...item }
356
+ function translateWithSchema(item, schema, context, translations) {
357
+ const translated = translateDocs(item, context, translations)
311
358
  translateItemWithSchema(translated, schema.fields, context, translations)
312
-
313
- // Translate ProseMirror content
314
- if (includeContent && translated.content?.type === 'doc') {
315
- translated.content = translateProseMirrorDoc(translated.content, context, translations)
316
- }
317
-
318
359
  return translated
319
360
  }
320
361
 
@@ -364,14 +405,9 @@ function translateItemWithSchema(data, fields, context, translations, topLevel =
364
405
  /**
365
406
  * Translate item fields using heuristics.
366
407
  */
367
- function translateHeuristic(item, context, translations, includeContent = true) {
368
- const translated = { ...item }
408
+ function translateHeuristic(item, context, translations) {
409
+ const translated = translateDocs(item, context, translations)
369
410
  translateItemHeuristic(translated, context, translations, 0)
370
-
371
- if (includeContent && translated.content?.type === 'doc') {
372
- translated.content = translateProseMirrorDoc(translated.content, context, translations)
373
- }
374
-
375
411
  return translated
376
412
  }
377
413
 
@@ -390,8 +426,8 @@ function translateItemHeuristic(data, context, translations, depth) {
390
426
  if (value === undefined || value === null) continue
391
427
  if (!Array.isArray(data) && isRecordSystemField(key, depth === 0)) continue
392
428
 
393
- // Skip ProseMirror content (handled separately)
394
- if (key === 'content' && typeof value === 'object' && value?.type === 'doc') continue
429
+ // Skip a ProseMirror document (translated separately)
430
+ if (isProseMirrorDoc(value)) continue
395
431
 
396
432
  if (typeof value === 'string') {
397
433
  if (!Array.isArray(data) && HEURISTIC_SKIP_FIELDS.has(key)) continue
@@ -423,18 +459,34 @@ function translateItemHeuristic(data, context, translations, depth) {
423
459
  * where there is no record on disk to be identified.
424
460
  */
425
461
  async function poolDirsByQuery(siteRoot) {
426
- const out = new Map()
462
+ return (await recordQueries(siteRoot)).poolDirs
463
+ }
464
+
465
+ /**
466
+ * Each query's pool directory (`poolDirsByQuery`) and the data schema its records are
467
+ * delivered in (`resolveRecordSchemas`, null for none) — what a free-form translation
468
+ * needs to put a body and frontmatter where the delivered record holds them.
469
+ *
470
+ * @returns {Promise<{ poolDirs: Map<string, string>, schemas: Map<string, Object|null> }>}
471
+ */
472
+ async function recordQueries(siteRoot) {
473
+ const poolDirs = new Map()
474
+ const schemas = new Map()
427
475
  try {
428
476
  const { declarations } = await resolveQueriesConfig(siteRoot)
429
- for (const [name, decl] of Object.entries(declarations || {})) {
477
+ const entries = Object.entries(declarations || {})
478
+ for (const [name, decl] of entries) {
430
479
  const dirs = decl.schema ? poolDirsForSchema(decl.schema) : null
431
- out.set(name, dirs ? dirs.join('/') : name)
480
+ poolDirs.set(name, dirs ? dirs.join('/') : name)
432
481
  }
482
+ const refs = entries.filter(([, d]) => d.url === undefined && d.schema).map(([, d]) => d.schema)
483
+ const resolved = (await resolveRecordSchemas(siteRoot, refs)).schemas
484
+ for (const [name, decl] of entries) schemas.set(name, (decl.schema && resolved[decl.schema]) || null)
433
485
  } catch {
434
486
  // No resolvable config — every record keys by its query name, which is what
435
487
  // the extractor did before records existed.
436
488
  }
437
- return out
489
+ return { poolDirs, schemas }
438
490
  }
439
491
 
440
492
 
@@ -558,9 +610,10 @@ async function pruneRecordFiles(recordsDir, dataDir, keep) {
558
610
  // ---------------------------------------------------------------------------
559
611
 
560
612
  /**
561
- * Extract from ProseMirror document
613
+ * Extract from a ProseMirror document — `field` is where the record holds it
614
+ * (`content`, `article_body.content`), which each unit's `field` starts with.
562
615
  */
563
- function extractFromProseMirrorDoc(doc, context, units) {
616
+ function extractFromProseMirrorDoc(doc, context, units, field = 'content') {
564
617
  if (!doc.content) return
565
618
 
566
619
  let headingIndex = 0
@@ -570,17 +623,17 @@ function extractFromProseMirrorDoc(doc, context, units) {
570
623
  if (node.type === 'heading') {
571
624
  const text = extractTextFromNode(node)
572
625
  if (text) {
573
- addUnit(units, text, `content.heading.${headingIndex}`, context)
626
+ addUnit(units, text, `${field}.heading.${headingIndex}`, context)
574
627
  headingIndex++
575
628
  }
576
629
  } else if (node.type === 'paragraph') {
577
630
  const text = extractTextFromNode(node)
578
631
  if (text) {
579
- addUnit(units, text, `content.paragraph.${paragraphIndex}`, context)
632
+ addUnit(units, text, `${field}.paragraph.${paragraphIndex}`, context)
580
633
  paragraphIndex++
581
634
  }
582
635
  } else if (node.type === 'bulletList' || node.type === 'orderedList') {
583
- extractFromList(node, context, units)
636
+ extractFromList(node, context, units, field)
584
637
  }
585
638
  }
586
639
  }
@@ -588,7 +641,7 @@ function extractFromProseMirrorDoc(doc, context, units) {
588
641
  /**
589
642
  * Extract from list nodes
590
643
  */
591
- function extractFromList(listNode, context, units) {
644
+ function extractFromList(listNode, context, units, field = 'content') {
592
645
  if (!listNode.content) return
593
646
 
594
647
  listNode.content.forEach((listItem, index) => {
@@ -597,7 +650,7 @@ function extractFromList(listNode, context, units) {
597
650
  if (child.type === 'paragraph') {
598
651
  const text = extractTextFromNode(child)
599
652
  if (text) {
600
- addUnit(units, text, `content.list.${index}`, context)
653
+ addUnit(units, text, `${field}.list.${index}`, context)
601
654
  }
602
655
  }
603
656
  }
@@ -690,7 +743,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
690
743
  // simply miss and every string falls back to its source. (This was missing for
691
744
  // a while and the failure was swallowed by the per-file catch below — the build
692
745
  // stayed green while nothing was translated.)
693
- const poolDirs = await poolDirsByQuery(siteRoot)
746
+ const { poolDirs, schemas: dataSchemas } = await recordQueries(siteRoot)
694
747
 
695
748
  const outputs = {}
696
749
  // Reported rather than only logged — see the catch below.
@@ -737,6 +790,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
737
790
  // Resolve schema once per collection
738
791
  const schema = await resolveSchema(queryName, siteRoot)
739
792
  const recordDir = poolDirs.get(queryName) ?? queryName
793
+ const dataSchema = dataSchemas.get(queryName) ?? null
740
794
 
741
795
  // Translate each item (with free-form support)
742
796
  const translatedItems = await Promise.all(
@@ -744,7 +798,8 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
744
798
  translateItemAsync(item, recordDir, translations, schema, {
745
799
  locale,
746
800
  localesDir,
747
- freeformEnabled: hasFreeform
801
+ freeformEnabled: hasFreeform,
802
+ dataSchema
748
803
  })
749
804
  )
750
805
  )
@@ -765,7 +820,7 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
765
820
  try {
766
821
  const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
767
822
  const translatedRecord = record && typeof record === 'object' && !Array.isArray(record)
768
- ? await translateItemAsync(record, recordDir, translations, schema, { locale, localesDir, freeformEnabled: hasFreeform })
823
+ ? await translateItemAsync(record, recordDir, translations, schema, { locale, localesDir, freeformEnabled: hasFreeform, dataSchema })
769
824
  : record
770
825
  await mkdir(localeRecordsDir, { recursive: true })
771
826
  await writeFile(join(localeRecordsDir, name), JSON.stringify(translatedRecord, null, 2))
@@ -803,42 +858,67 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
803
858
  /**
804
859
  * Apply translations to a collection item (async, with free-form support)
805
860
  *
806
- * Resolution order:
807
- * 1. Check for free-form translation (complete or partial replacement)
808
- * 2. Fall back to hash-based translation (schema-guided or heuristic)
861
+ * The hash-based translations apply first — every field and every ProseMirror document
862
+ * (schema-guided or heuristic) — and a free-form translation then replaces what it
863
+ * states (`applyFreeform`): its frontmatter, its body, or both.
864
+ *
865
+ * @param {Object} [options.dataSchema] - the data schema the record is delivered in
866
+ * (`recordQueries`), which says where a free-form body and frontmatter go
809
867
  */
810
868
  async function translateItemAsync(item, recordDir, translations, schema, options = {}) {
811
- const { locale, localesDir, freeformEnabled } = options
812
- const translated = { ...item }
813
- const slug = item.slug || item.id || item.name || 'unknown'
814
- const context = { record: `${recordDir}/${slug}` }
815
-
816
- // Check for free-form translation first. ⛔ The loader takes the record's SCHEMA ref
817
- // and derives the pool path from it (`buildFreeformRecordPath`); handed the pool
818
- // directory itself (`article`), it derived nothing and found no file — so until
819
- // 2026-09-14 no free-form record translation applied on this lane.
820
- if (freeformEnabled && locale && localesDir) {
821
- const freeform = await loadFreeformRecord(item, schemaForPoolDirs(recordDir.split('/')), locale, localesDir)
822
-
823
- if (freeform) {
824
- // Merge free-form data (supports partial: frontmatter only, body only, or both)
825
- if (freeform.frontmatter) {
826
- Object.assign(translated, freeform.frontmatter)
827
- }
828
- if (freeform.content) {
829
- translated.content = freeform.content
830
- // Skip hash-based content translation since we have free-form
831
- // Still translate frontmatter fields via schema/heuristic
832
- if (schema?.fields) {
833
- return translateWithSchema(translated, schema, context, translations, false)
834
- }
835
- return translateHeuristic(translated, context, translations, false)
869
+ const { locale, localesDir, freeformEnabled, dataSchema = null } = options
870
+ const translated = translateItemSync(item, recordDir, translations, schema)
871
+ if (!freeformEnabled || !locale || !localesDir) return translated
872
+
873
+ // ⛔ The loader takes the record's SCHEMA ref and derives the pool path from it
874
+ // (`buildFreeformRecordPath`); handed the pool directory itself (`article`), it
875
+ // derived nothing and found no file — so until 2026-09-14 no free-form record
876
+ // translation applied on this lane. And it is named by the record's HANDLE
877
+ // (`recordHandle`), never a brief's `slug` field.
878
+ const schemaRef = schemaForPoolDirs(recordDir.split('/'))
879
+ const freeform = await loadFreeformRecord({ slug: item.$name ?? item.slug }, schemaRef, locale, localesDir)
880
+ return freeform ? applyFreeform(translated, freeform, dataSchema, schemaRef) : translated
881
+ }
882
+
883
+ /**
884
+ * A free-form translation over a record, in the shape the record is DELIVERED in (see
885
+ * `site/query-processor.js::deliverRecord`): its frontmatter read the way the record's
886
+ * file is — by section, the brief lifted to the top — and merged section by section; its
887
+ * body put in the schema's content body field — the markdown source for a markup `text`
888
+ * field — or at `content` for a record with no schema or no such field. ⛔ Until
889
+ * 2026-09-24 both went to the top of the record, where a delivered `@std/article` holds
890
+ * neither its body nor anything of `article_body`.
891
+ *
892
+ * ⛔ Frontmatter written in the retired flat form is refused, as the record's own file is.
893
+ */
894
+ function applyFreeform(record, freeform, dataSchema, schemaRef) {
895
+ const out = { ...record }
896
+ if (freeform.frontmatter) {
897
+ let fm = freeform.frontmatter
898
+ if (dataSchema) {
899
+ const misplaced = misplacedFields(dataSchema, fm)
900
+ if (misplaced.length) {
901
+ throw new Error(flatFormRefusal(freeform.relativePath ?? freeform.filePath, schemaRef, dataSchema, misplaced))
836
902
  }
903
+ fm = toDeliveredRecord(dataSchema, fm)
904
+ }
905
+ const sections = new Set(Object.keys(dataSchema?.sections || {}))
906
+ for (const [key, value] of Object.entries(fm)) {
907
+ out[key] = sections.has(key) && isPlainObject(out[key]) && isPlainObject(value) ? { ...out[key], ...value } : value
837
908
  }
838
909
  }
910
+ if (freeform.content) {
911
+ const target = dataSchema ? contentBodyField(dataSchema) : null
912
+ const value = target?.field?.type === 'text' ? freeform.markdown : freeform.content
913
+ if (!target) out.content = freeform.content
914
+ else if (!target.section) out[target.key] = value
915
+ else out[target.section] = { ...(isPlainObject(out[target.section]) ? out[target.section] : {}), [target.key]: value }
916
+ }
917
+ return out
918
+ }
839
919
 
840
- // Fall back to hash-based translation
841
- return translateItemSync(translated, recordDir, translations, schema)
920
+ function isPlainObject(value) {
921
+ return !!value && typeof value === 'object' && !Array.isArray(value)
842
922
  }
843
923
 
844
924
  /**
@@ -846,8 +926,7 @@ async function translateItemAsync(item, recordDir, translations, schema, options
846
926
  */
847
927
  function translateItemSync(item, recordDir, translations, schema) {
848
928
  const translated = { ...item }
849
- const slug = item.slug || item.id || item.name || 'unknown'
850
- const context = { record: `${recordDir}/${slug}` }
929
+ const context = { record: `${recordDir}/${recordHandle(item)}` }
851
930
 
852
931
  if (schema?.fields) {
853
932
  return translateWithSchema(translated, schema, context, translations)
@@ -962,7 +1041,8 @@ export async function translateRecordData(items, queryName, siteRoot, options =
962
1041
  const records = one ? [items] : items
963
1042
 
964
1043
  const schema = await resolveSchema(queryName, siteRoot)
965
- const recordDir = (await poolDirsByQuery(siteRoot)).get(queryName) ?? queryName
1044
+ const { poolDirs, schemas: dataSchemas } = await recordQueries(siteRoot)
1045
+ const recordDir = poolDirs.get(queryName) ?? queryName
966
1046
 
967
1047
  const translated = freeformEnabled
968
1048
  ? await Promise.all(
@@ -970,7 +1050,8 @@ export async function translateRecordData(items, queryName, siteRoot, options =
970
1050
  translateItemAsync(item, recordDir, translations, schema, {
971
1051
  locale,
972
1052
  localesDir,
973
- freeformEnabled
1053
+ freeformEnabled,
1054
+ dataSchema: dataSchemas.get(queryName) ?? null
974
1055
  })
975
1056
  )
976
1057
  )
@@ -72,6 +72,13 @@ export {
72
72
  // across foundations — locally, with no backend. '@uniweb' is reserved for the
73
73
  // platform system namespace and is never a data-schema source.
74
74
  const SCOPE_PACKAGE = { std: '@uniweb/schemas' }
75
+
76
+ /**
77
+ * The `code` of the error a `@/name` ref raises when the foundation defines no schema of
78
+ * that name — the one failure that is an answer rather than a fault: a query named after
79
+ * no schema is a query over schema-less records.
80
+ */
81
+ export const SCHEMA_NOT_FOUND = 'SCHEMA_NOT_FOUND'
75
82
  const RESERVED_SYSTEM_SCOPE = 'uniweb'
76
83
  const packageForScope = (scope) => SCOPE_PACKAGE[scope] ?? `@${scope}/schemas`
77
84
 
@@ -121,7 +128,10 @@ export async function resolveSchemaRef(ref, { srcDir, aliases }) {
121
128
  const file = findSelfSchemaFile(srcDir, name)
122
129
  if (!file) {
123
130
  const tried = SCHEMA_EXTENSIONS.map((e) => `schemas/${name}${e}`).join(', ')
124
- throw new Error(`Data schema '${ref}' not found. Expected one of: ${tried} under the foundation root.`)
131
+ throw Object.assign(
132
+ new Error(`Data schema '${ref}' not found. Expected one of: ${tried} under the foundation root.`),
133
+ { code: SCHEMA_NOT_FOUND }
134
+ )
125
135
  }
126
136
  return validateAndNormalizeSchema(await loadSchemaFile(file), ref)
127
137
  }
@@ -25,7 +25,7 @@
25
25
  */
26
26
 
27
27
  import { isRichSchema } from '@uniweb/core'
28
- import { flatRecordFields } from '@uniweb/schemas/conform'
28
+ import { deliveredFields } from '@uniweb/schemas/conform'
29
29
 
30
30
  /**
31
31
  * A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
@@ -193,22 +193,20 @@ function leanDataSchema(value, dataSchemaMap) {
193
193
  : (value && typeof value.schema === 'string' ? value.schema : null)
194
194
  if (ref) {
195
195
  const resolved = dataSchemaMap[ref]
196
- // ⛔ `flatRecordFields`, NOT `resolved.fields`. `dataSchemaMap` holds each
197
- // schema NORMALIZED but still in the form it was authored in — `fields:` or
198
- // `sections:` — because only lowering (`uwx/data-schema.js`) folds the two
199
- // forms into one. (This said "AS AUTHORED" until 2026-09-22; aliases and
200
- // `many:` are already folded here.) `fields:` at the top is the
201
- // SUGAR for a one-section model; a sections-form schema has no such key, so
202
- // reading it directly returned null for every `@std/*` binding and the
203
- // section's `data:` declaration supplied no field defaults at all. Silent:
204
- // the data still arrived, just without its defaults.
196
+ // ⭐ THE DELIVERED RECORD'S FIELDS (`deliveredFields`) — the brief's at the top and each
197
+ // other section as one field under its name — so a default lands where the record
198
+ // a component receives carries its field: `@std/article`'s `status` inside
199
+ // `article_body`, as the records service delivers it (measured 2026-09-24). A section
200
+ // is filled only when the record holds it (`applySchemaToObject` recurses into what is
201
+ // there), so a list of briefs gains no section it was not sent.
205
202
  //
206
- // This helper is the reader that takes both forms (`if (schema.fields)
207
- // return schema.fields`, else walk the single sections), and
208
- // `site/queries-config.js` already used it for exactly this reason.
209
- // Measured 2026-09-03: `@std/article` → undefined here, 15 fields through
210
- // the helper; `@/member` (sugar) → 4 either way.
211
- const fields = flatRecordFields(resolved)
203
+ // ⛔ It was `flatRecordFields` until 2026-09-24 — the retired flat form, every single
204
+ // section's fields at the top — so on a delivered record those defaults landed beside
205
+ // the section they belong to. And before 2026-09-03 it read `resolved.fields`, which a
206
+ // sections-form schema does not have: no defaults at all. `dataSchemaMap` holds each
207
+ // schema normalized in its authored form (`fields:` or `sections:`), and only these
208
+ // readers say what one record of it looks like.
209
+ const fields = deliveredFields(resolved)
212
210
  if (!fields) return null
213
211
  const lean = extractSchemaFields(fields)
214
212
  return Object.keys(lean).length > 0 ? lean : null