@uniweb/build 0.64.1 → 0.65.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.
@@ -58,15 +58,17 @@ import { createHash } from 'node:crypto'
58
58
  import yaml from 'js-yaml'
59
59
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
60
60
  import { parseBibtex } from '@citestyle/bibtex'
61
- import { DATA_DIR, withoutRouteVariables } from '@uniweb/core'
61
+ import { DATA_DIR, withoutRouteVariables, mapQueryPaths } from '@uniweb/core'
62
62
  import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage, refuseQueryRoute, refuseLimit } from './data-fetcher.js'
63
63
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
64
64
  import { readEntityPool, groupPoolBySchema, poolDirsForSchema } from './entity-pool.js'
65
- import { readRecordsConfig, resolveFolder } from './records-config.js'
65
+ import { readRecordsConfig, resolveFolder, folderTreeOrder } from './records-config.js'
66
66
  import { isDraftRecord } from './record-draft.js'
67
- import { resolveRecordSchemas } from './queries-config.js'
67
+ import { refuseNotOneRecord } from './record-file.js'
68
+ import { resolveRecordSchemas, resolveQueriesConfig, foundationSections } from './queries-config.js'
69
+ import { dataKeyTypes } from '../uwx/data-key-types.js'
68
70
  import { parseFrontmatter } from '../utils/frontmatter.js'
69
- import { toDeliveredRecord, contentBodyField, misplacedFields, mapReferences, recordLayout } from '@uniweb/schemas/conform'
71
+ import { toDeliveredRecord, toStoredRecord, storedPath, briefFieldMap, contentBodyField, misplacedFields, mapReferences, recordLayout } from '@uniweb/schemas/conform'
70
72
  import { collectNestedRefs } from '@uniweb/schemas/format'
71
73
 
72
74
  // Try to import content-reader for markdown parsing
@@ -125,7 +127,6 @@ function parseQueryConfig(name, config) {
125
127
  filter: null,
126
128
  limit: 0,
127
129
  excerpt: { maxLength: 160 },
128
- deferred: null,
129
130
  }
130
131
  }
131
132
 
@@ -153,14 +154,6 @@ function parseQueryConfig(name, config) {
153
154
  maxLength: config.excerpt?.maxLength || 160,
154
155
  field: config.excerpt?.field || null
155
156
  },
156
- // `deferred:` lists fields that are heavy (article body, full nested
157
- // arrays). Those fields are stripped from the cascade payload that
158
- // ships with `query: <name>` declarations, and per-record full files
159
- // are emitted at public/data/<name>/<slug>.json. Components that
160
- // need the full record fetch the per-record file on demand, either
161
- // automatically on dynamic-route pages (entity-store routes the
162
- // singular detail there) or via kit's useWholeRecord hook.
163
- deferred: Array.isArray(config.deferred) ? config.deferred.slice() : null,
164
157
  // `queryable:` declares the queryable surface — which fields a
165
158
  // foundation can offer for filtering UI, with their type and
166
159
  // type-specific metadata (enum options, range bounds). Foundations
@@ -458,15 +451,13 @@ async function processDataItemAssets(data, itemPath, siteRoot, recordsRoot, base
458
451
  * YAML items are pure data — no ProseMirror conversion, no body, no excerpt,
459
452
  * no image extraction, no lastModified.
460
453
  *
461
- * A YAML file containing a top-level array returns all items (single-file
462
- * collection); each item must carry its own `slug`. A YAML file containing
463
- * a mapping returns a single item with `slug` derived from the filename.
464
- * Mirrors `processJsonItem` for parity across pure-data formats.
454
+ * A YAML file holds ONE record, named by the file (`record-file.js` refuses a list
455
+ * or a `slug:`). Mirrors `processJsonItem` for parity across pure-data formats.
465
456
  *
466
457
  * @param {string} dir - Collection directory path
467
458
  * @param {string} filename - YAML filename (.yml or .yaml)
468
459
  * @param {Object} config - the parsed query (`includeDrafts`, `dataSchema`)
469
- * @returns {Promise<Object|Array|null>} Processed item(s), or null for a draft this run withholds
460
+ * @returns {Promise<Object|null>} The processed record, or null for a draft this run withholds
470
461
  */
471
462
  async function processDataItem(dir, filename, siteRoot, recordsRoot, basePath, config) {
472
463
  const filepath = join(dir, filename)
@@ -477,26 +468,11 @@ async function processDataItem(dir, filename, siteRoot, recordsRoot, basePath, c
477
468
  }
478
469
 
479
470
  /**
480
- * The records a YAML or JSON file holds, delivered (`deliverRecord`): an array → one
481
- * record per entry, each carrying its own `slug`; a mapping → one record, its `slug` the
482
- * file's name unless it states one.
471
+ * The record a YAML or JSON file holds, delivered (`deliverRecord`) and named by the file.
483
472
  */
484
473
  async function processDataRecords(data, fileSlug, filepath, where, siteRoot, recordsRoot, basePath, config) {
485
- // Array → multiple items (single-file collection). A draft entry is dropped
486
- // BEFORE its assets are copied, so nothing of it ships.
487
- if (Array.isArray(data)) {
488
- const out = []
489
- for (const [i, item] of data.entries()) {
490
- if (withheld(item, `${where} [${i}]`, config.includeDrafts)) continue
491
- if (item && typeof item === 'object') {
492
- await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
493
- }
494
- out.push(deliverRecord(item, config, `${where} [${i}]`))
495
- }
496
- return out
497
- }
498
-
499
- // Mapping → single item
474
+ // ONE record per file, named by the file (`record-file.js`).
475
+ refuseNotOneRecord(data, where, fileSlug)
500
476
  if (withheld(data, where, config.includeDrafts)) return null
501
477
  const item = { slug: fileSlug, ...data }
502
478
  await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
@@ -507,13 +483,12 @@ async function processDataRecords(data, fileSlug, filepath, where, siteRoot, rec
507
483
  * Process a single data item from a JSON file
508
484
  *
509
485
  * JSON items are pure data — like YAML items, no ProseMirror conversion.
510
- * A JSON file containing an array returns all items (single-file collection).
511
- * A JSON file containing an object returns a single item with slug from filename.
486
+ * A JSON file holds ONE record, named by the file (`record-file.js`).
512
487
  *
513
488
  * @param {string} dir - Collection directory path
514
489
  * @param {string} filename - JSON filename
515
490
  * @param {Object} config - the parsed query (`includeDrafts`, `dataSchema`)
516
- * @returns {Promise<Object|Array|null>} Processed item(s), or null for a draft this run withholds
491
+ * @returns {Promise<Object|null>} The processed record, or null for a draft this run withholds
517
492
  */
518
493
  async function processJsonItem(dir, filename, siteRoot, recordsRoot, basePath, config) {
519
494
  const filepath = join(dir, filename)
@@ -527,8 +502,8 @@ async function processJsonItem(dir, filename, siteRoot, recordsRoot, basePath, c
527
502
  * Process a single BibTeX file into an array of CSL-JSON bibliography items.
528
503
  *
529
504
  * Each `@entry{key, ...}` becomes one item. The BibTeX cite key is preserved
530
- * as `id` (CSL-JSON convention) and copied to `slug` so per-record file
531
- * emission and runtime lookups behave the same as for other formats.
505
+ * as `id` (CSL-JSON convention) and names the record — its `$name` — as a file's
506
+ * name does for other formats.
532
507
  *
533
508
  * No asset processing — bibliography records reference URLs and DOIs, not
534
509
  * local files.
@@ -537,7 +512,7 @@ async function processJsonItem(dir, filename, siteRoot, recordsRoot, basePath, c
537
512
  * @param {string} filename - BibTeX filename (.bib)
538
513
  * @param {string} siteRoot - Site root, for messages
539
514
  * @param {boolean} [includeDrafts] - keep `draft: true` records (a preview)
540
- * @returns {Promise<Array<Object>>} Array of CSL-JSON items, each with `slug`
515
+ * @returns {Promise<Array<Object>>} Array of CSL-JSON items, each with `$name`
541
516
  */
542
517
  async function processBibtexItem(dir, filename, siteRoot, includeDrafts = false) {
543
518
  const filepath = join(dir, filename)
@@ -546,7 +521,7 @@ async function processBibtexItem(dir, filename, siteRoot, includeDrafts = false)
546
521
  const entries = parseBibtex(raw)
547
522
  return entries
548
523
  .filter(entry => entry && entry.id && !withheld(entry, `${where} @${entry.id}`, includeDrafts))
549
- .map(entry => ({ slug: entry.id, ...entry }))
524
+ .map(entry => ({ ...entry, $name: String(entry.id) }))
550
525
  }
551
526
 
552
527
  /**
@@ -575,6 +550,7 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
575
550
 
576
551
  // Parse frontmatter and body
577
552
  const { frontmatter, body } = parseFrontmatter(raw, filepath)
553
+ refuseNotOneRecord(frontmatter, relative(siteRoot, filepath), slug)
578
554
 
579
555
  // A draft is left out — before its assets are copied, so nothing of it ships.
580
556
  if (withheld(frontmatter, relative(siteRoot, filepath), config.includeDrafts)) {
@@ -598,6 +574,15 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
598
574
  return deliverRecord({ slug, ...frontmatter }, config, relative(siteRoot, filepath), { doc: content, markdown: body })
599
575
  }
600
576
 
577
+ /**
578
+ * ⭐ WHAT A QUERY'S FILES HOLD, beside the merged view this module evaluates a query over (ruled
579
+ * 2026-09-27 [Diego]): each record's BRIEF, which its list holds — the brief's fields at the top —
580
+ * and the record WHOLE, as stored, which its own file holds — a key per section, the brief's
581
+ * included. Carried on each compiled item under these keys, which `JSON.stringify` never writes.
582
+ */
583
+ export const BRIEF_RECORD = Symbol.for('uniweb.query.brief')
584
+ export const WHOLE_RECORD = Symbol.for('uniweb.query.whole')
585
+
601
586
  /**
602
587
  * One record as its file holds it → the record a component receives.
603
588
  *
@@ -605,11 +590,13 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
605
590
  * backend: the brief's fields at the top and every other section under its own name
606
591
  * (`@uniweb/schemas/conform`'s `toDeliveredRecord`), with a markdown body in the
607
592
  * schema's content body field — the ProseMirror document for a `format: prosemirror`
608
- * field (`article_body.content`, for `@std/article`), the markdown source for a markup
609
- * `text` one. A component is written against one shape, so a static site hands it that
610
- * one (ruled 2026-09-24 [Diego]: the same as hosted). ⛔ Until then a record reached a
611
- * component as its file held it, its body in `content` at the top — a shape no host
612
- * delivers.
593
+ * field (`body.content`, for `@std/article`), the markdown source for a markup
594
+ * `text` one. A component is written against one shape, so both lanes deliver the same
595
+ * one — ruled 2026-09-24 [Diego] as a requirement that the shape be one AND a good one,
596
+ * not as an instruction to adopt the host's. ⚠️ It was built by adopting the host's as it
597
+ * was (read until 2026-09-26 as "the same as hosted"), and that shape puts two namespaces
598
+ * in one object (`@uniweb/schemas/conform`, above `toDeliveredRecord`). ⛔ Until 2026-09-24
599
+ * a record reached a component as its file held it, its body in `content` at the top.
613
600
  *
614
601
  * `$name` is the record's handle, taken BEFORE the lift: a brief may declare a `slug`
615
602
  * field of its own (`@std/article`'s does), and the lift puts that one over the file's.
@@ -625,14 +612,19 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
625
612
  * ⛔ A record written in the retired flat form (`misplacedFields`) STOPS THE BUILD, as it
626
613
  * stops a push: which section a key belongs in is not a guess to make.
627
614
  *
628
- * @param {*} record - the record as its file holds it, `slug` included
615
+ * @param {*} record - the record as its file holds it, with `slug`: the file's name, added by its reader
629
616
  * @param {Object} config - the parsed query (`dataSchema`, `excerpt`)
630
- * @param {string} where - the file, and the entry in a file of several, for a message
617
+ * @param {string} where - the file, for a message
631
618
  * @param {{ doc: Object, markdown: string }|null} [body] - a markdown record's body
632
619
  * @returns {*} the delivered record (anything but a record, as it came)
633
620
  */
634
- function deliverRecord(record, config, where, body = null) {
635
- if (!record || typeof record !== 'object' || Array.isArray(record)) return record
621
+ function deliverRecord(file, config, where, body = null) {
622
+ if (!file || typeof file !== 'object' || Array.isArray(file)) return file
623
+ // ⭐ The record's NAME rides as `$name`, and nowhere else. `slug` is how its reader hands the
624
+ // file's name to here — the file's stem — and never a key of what a component
625
+ // receives: a records service serves no `slug`, and a field of that name has no meaning to
626
+ // the framework [Diego, 2026-09-27]. ⛔ Until then the delivered record carried `slug` too.
627
+ const { slug: handle, ...record } = file
636
628
  const schema = config.dataSchema || null
637
629
  let out
638
630
  if (!schema) {
@@ -655,12 +647,58 @@ function deliverRecord(record, config, where, body = null) {
655
647
  out.image = out.image || extractFirstImage(body.doc)
656
648
  }
657
649
  if (config.references) out = config.references.deliver(schema, out, where)
650
+ out[WHOLE_RECORD] = storedRecord(record, schema, config, where, body, out)
651
+ }
652
+ if (handle !== undefined && handle !== null && handle !== '') {
653
+ out.$name = String(handle)
654
+ if (out[WHOLE_RECORD]) {
655
+ const { $uuid, ...rest } = out[WHOLE_RECORD]
656
+ out[WHOLE_RECORD] = { ...($uuid !== undefined ? { $uuid } : {}), $name: out.$name, ...rest }
657
+ }
658
658
  }
659
- const handle = record.slug
660
- if (handle !== undefined && handle !== null && handle !== '') out.$name = String(handle)
661
659
  return out
662
660
  }
663
661
 
662
+ /**
663
+ * The record WHOLE, as stored — what its own file serves and a whole question answers: its file's
664
+ * fields under their sections (`toStoredRecord`), its markdown body in the content body field's
665
+ * section, its references hydrated as a host hydrates them. An excerpt and an image the build
666
+ * derives go in its brief only where the brief declares them.
667
+ */
668
+ function storedRecord(record, schema, config, where, body, merged) {
669
+ let file = { ...record }
670
+ if (body) placeBodyInFile(file, schema, body)
671
+ if (config.references) file = config.references.deliver(schema, file, where, { file: true })
672
+ const stored = toStoredRecord(schema, file)
673
+ const layout = recordLayout(schema)
674
+ const briefName = schema.fields ? 'brief' : (layout?.flat ? layout.sections[0][0] : layout?.brief)
675
+ const declared = schema.fields ?? (layout?.sections.find(([n]) => n === briefName)?.[1]?.fields || {})
676
+ if (briefName && stored[briefName] && typeof stored[briefName] === 'object') {
677
+ for (const key of ['excerpt', 'image']) {
678
+ if (declared[key] && stored[briefName][key] == null && merged[key] != null) {
679
+ stored[briefName] = { ...stored[briefName], [key]: merged[key] }
680
+ }
681
+ }
682
+ }
683
+ return stored
684
+ }
685
+
686
+ /** A markdown body placed where the record's FILE holds its content body field (`fileSection`). */
687
+ function placeBodyInFile(file, schema, body) {
688
+ const blank = !body.markdown || !body.markdown.trim()
689
+ const target = contentBodyField(schema)
690
+ if (!target || blank) return
691
+ const value = target.field?.type === 'text' ? body.markdown : body.doc
692
+ if (!target.fileSection) {
693
+ if (file[target.key] == null) file[target.key] = value
694
+ return
695
+ }
696
+ const held = file[target.fileSection]
697
+ if (held != null && (typeof held !== 'object' || Array.isArray(held))) return
698
+ if (held?.[target.key] != null) return
699
+ file[target.fileSection] = { ...(held || {}), [target.key]: value }
700
+ }
701
+
664
702
  /**
665
703
  * Put a markdown body where the delivered record carries its schema's content body
666
704
  * field (`contentBodyField`) — unless the record already states that field, or the body
@@ -760,18 +798,18 @@ async function collectSourceFiles(dir, rel = '') {
760
798
  function warnDuplicateSlugs(items, queryName) {
761
799
  const seen = new Map()
762
800
  for (const item of items) {
763
- if (!item || item.slug === undefined) continue
764
- const slug = String(item.slug)
801
+ if (!item || item.$name === undefined) continue
802
+ const name = String(item.$name)
765
803
  const where = item.path ? `${item.path}/` : ''
766
- if (seen.has(slug)) {
804
+ if (seen.has(name)) {
767
805
  console.warn(
768
- `[query-processor] Query "${queryName}" has more than one record with ` +
769
- `slug "${slug}" (${seen.get(slug)}${slug}, ${where}${slug}). Its detail route and ` +
770
- `per-record file resolve to only one of them — give them distinct slugs.`
806
+ `[query-processor] Query "${queryName}" has more than one record named ` +
807
+ `"${name}" (${seen.get(name)}${name}, ${where}${name}). Its detail route and ` +
808
+ `per-record file resolve to only one of them — give them distinct names.`
771
809
  )
772
810
  continue
773
811
  }
774
- seen.set(slug, where)
812
+ seen.set(name, where)
775
813
  }
776
814
  }
777
815
 
@@ -849,7 +887,7 @@ function referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, ba
849
887
  if (!targets.has(ref)) targets.set(ref, await load(ref))
850
888
  }
851
889
  },
852
- deliver(schema, record, where) {
890
+ deliver(schema, record, where, { file = false } = {}) {
853
891
  return mapReferences(
854
892
  schema,
855
893
  record,
@@ -863,7 +901,7 @@ function referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, ba
863
901
  }
864
902
  return value
865
903
  },
866
- { delivered: true }
904
+ { delivered: !file }
867
905
  )
868
906
  },
869
907
  }
@@ -894,6 +932,14 @@ function pickBrief(record, keys) {
894
932
  return out
895
933
  }
896
934
 
935
+ // A query's records in folder-tree order (`folderTreeOrder`), before anything reads them — so
936
+ // `where`, `sort` (stable) and `limit` start from the order a records service answers in.
937
+ function inFolderOrder(pooled, rank) {
938
+ if (!rank || pooled.length < 2) return pooled
939
+ const at = (e) => rank.get(e.id) ?? Number.POSITIVE_INFINITY
940
+ return [...pooled].sort((a, b) => at(a) - at(b))
941
+ }
942
+
897
943
  /**
898
944
  * Collect and process all of a query's records
899
945
  *
@@ -907,7 +953,7 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
907
953
  // sync lane makes, from the same reader, so the two lanes cannot disagree
908
954
  // about which files are a query's records. They used to: this one recursed
909
955
  // into a collection directory and sync did not.
910
- const pooled = config.poolEntities || []
956
+ const pooled = inFolderOrder(config.poolEntities || [], config.folderRank)
911
957
  if (pooled.length === 0) return []
912
958
 
913
959
  // The records this query's references name, read before its own — delivering one
@@ -938,8 +984,8 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
938
984
  return result && { ...result, path }
939
985
  })
940
986
 
941
- // Flatten one level: array-form YAML/JSON files and every .bib file
942
- // contribute their entries individually.
987
+ // Flatten one level: a .bib file contributes its entries individually. Every other
988
+ // record file is one record (`record-file.js`).
943
989
  items = items.flat()
944
990
 
945
991
  // Filter out nulls (drafts this run withholds)
@@ -947,19 +993,12 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
947
993
 
948
994
  // ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
949
995
  // field a `[slug]` or `[...path]` page matches, and the one the records service
950
- // serves. It is the record's FINAL slug — a frontmatter `slug:` (which wins over the
951
- // filename), a BibTeX cite key and an array-form file's own `slug` all count — exactly
952
- // what our sync sends as the entry's name (`uwx/entity-source.js`). `deliverRecord`
953
- // sets it for a markdown, YAML or JSON record, before the brief is lifted (a brief's
954
- // own `slug` FIELD must not become the handle); a BibTeX entry gets it here. `slug`
955
- // stays: foundations and templates read it.
956
- items = items.map((item) => (
957
- item && typeof item === 'object' && item.$name === undefined &&
958
- item.slug !== undefined && item.slug !== null && item.slug !== ''
959
- ? { ...item, $name: String(item.slug) }
960
- : item
961
- ))
962
-
996
+ // serves: the name its file gives it — the file's stem, or a BibTeX entry's cite key
997
+ // (a file's own `slug:` renamed it, and a list file held several, until 2026-09-27;
998
+ // both are refused now, `record-file.js`) — exactly what our sync
999
+ // sends as the entry's name (`uwx/entity-source.js`). `deliverRecord` and
1000
+ // `processBibtexItem` set it. ⛔ Until 2026-09-27 the delivered record carried `slug`
1001
+ // beside it, and this filled `$name` from it; no record carries `slug` now.
963
1002
  warnDuplicateSlugs(items, config.name)
964
1003
 
965
1004
  // ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where, then sort. Two
@@ -980,16 +1019,27 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
980
1019
  //
981
1020
  // ⛔ `scope` is NEVER baked, fixed or routed — the runtime applies it, as the
982
1021
  // records service does, so a routed `scope: :dir` binds per page.
1022
+ //
1023
+ // ⭐ A PATH READS THE RECORD AS STORED, as the records service reads it (ruled 2026-09-27
1024
+ // [Diego]): `details` is the brief's field and `details.pages` the
1025
+ // section's, even where the two share a name — which the merged view the records are carried in
1026
+ // cannot hold apart. A record with no data schema is read as it is.
983
1027
  const fixed = withoutRouteVariables({ where: config.where })
984
- if (fixed.where) {
985
- items = applyWhere(items, fixed.where)
1028
+ const asked = config.dataSchema
1029
+ ? mapQueryPaths({ where: fixed.where, sort: config.sort }, (path) => storedPath(config.dataSchema, path))
1030
+ : { where: fixed.where, sort: config.sort }
1031
+ const read = (item) => (item && item[WHOLE_RECORD]) || item
1032
+ if (asked.where) {
1033
+ const kept = new Set(applyWhere(items.map(read), asked.where))
1034
+ items = items.filter((item) => kept.has(read(item)))
986
1035
  }
987
1036
 
988
1037
  // The query's `sort` orders the file; the runtime applies whichever sort wins —
989
1038
  // a binding's own, else this one — so the order here only spares it the work.
990
1039
  // Texts collate in the site's default language; each page re-sorts in its own.
991
- if (config.sort) {
992
- items = applySort(items, config.sort, { locale })
1040
+ if (asked.sort) {
1041
+ const itemOf = new Map(items.map((item) => [read(item), item]))
1042
+ items = applySort([...itemOf.keys()], asked.sort, { locale }).map((held) => itemOf.get(held))
993
1043
  }
994
1044
 
995
1045
  // ⛔ `limit` IS NEVER BAKED. A query's `limit` is part of its set (ruled 2026-09-14
@@ -1002,9 +1052,43 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
1002
1052
  // file — before a page's `scope` or routed clauses, which the runtime has applied
1003
1053
  // since 2026-09-11, could select anything from it.
1004
1054
 
1055
+ // ⭐ EACH RECORD'S BRIEF — what its list holds: the brief's fields at the top, beside the record's
1056
+ // own keys and what the build derives (`excerpt`, `image`, `path`). A record of a schema with no
1057
+ // brief, or of one with a single section, is its brief as it is.
1058
+ const briefOf = briefBuilder(config.dataSchema)
1059
+ if (briefOf) {
1060
+ items = items.map((item) =>
1061
+ item && typeof item === 'object' && !Array.isArray(item) ? { ...item, [BRIEF_RECORD]: briefOf(item) } : item
1062
+ )
1063
+ }
1064
+
1005
1065
  return items
1006
1066
  }
1007
1067
 
1068
+ /**
1069
+ * A record's BRIEF, built from the record AS STORED — its brief section's fields — beside the keys of
1070
+ * the merged view that are neither a section nor a brief field. ⛔ Not the merged view less the other
1071
+ * sections: sections are namespaces (ruled 2026-09-26 [Diego]), so a brief field may share a
1072
+ * section's name, and the merged view holds only one of the two. Null when a list is the record as
1073
+ * it is.
1074
+ */
1075
+ function briefBuilder(schema) {
1076
+ const layout = schema ? recordLayout(schema) : null
1077
+ if (!layout || schema.fields || layout.flat || !layout.brief) return null
1078
+ const sections = new Set(layout.sections.map(([name]) => name))
1079
+ const fields = new Set(Object.keys(briefFieldMap(schema) || {}))
1080
+ return (item) => {
1081
+ const stored = item[WHOLE_RECORD]
1082
+ const held = stored?.[layout.brief]
1083
+ const brief = held && typeof held === 'object' && !Array.isArray(held) ? held : null
1084
+ const out = {}
1085
+ for (const [key, value] of Object.entries(item)) {
1086
+ if (!sections.has(key) && !(brief && fields.has(key))) out[key] = value
1087
+ }
1088
+ return brief ? { ...out, ...brief } : out
1089
+ }
1090
+ }
1091
+
1008
1092
  /**
1009
1093
  * Compile every query over the site's records
1010
1094
  *
@@ -1061,6 +1145,10 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1061
1145
  const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
1062
1146
  for (const e of folder.errors) console.error(`[query-processor] ${e}`)
1063
1147
  for (const w of folder.warnings) console.warn(`[query-processor] ${w}`)
1148
+ // ⭐ A QUESTION WITH NO `sort` ANSWERS IN FOLDER-TREE ORDER (ruled 2026-09-27 [Diego]) — the
1149
+ // order a backend's records service answers in, over the tree a push places there. ⛔ Until then
1150
+ // this lane answered in the order the directory walk found the files.
1151
+ const folderRank = new Map(folderTreeOrder(folder.nodes).map((id, i) => [id, i]))
1064
1152
 
1065
1153
  const poolBySchema = groupPoolBySchema(pool.entities)
1066
1154
  const recordsRoot = resolve(siteDir, pool.dir)
@@ -1081,6 +1169,7 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1081
1169
  const parsed = parseQueryConfig(name, config)
1082
1170
  parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
1083
1171
  parsed.placements = folder.placements
1172
+ parsed.folderRank = folderRank
1084
1173
  parsed.includeDrafts = includeDrafts
1085
1174
  parsed.dataSchema = (parsed.schema && dataSchemas[parsed.schema]) || null
1086
1175
  parsed.references = references
@@ -1121,13 +1210,51 @@ async function querySchemas(siteDir, queriesConfig) {
1121
1210
  `their files hold them rather than as a component receives them: ${message}`
1122
1211
  )
1123
1212
  }
1213
+ await typeByDataKey(siteDir, queriesConfig, schemas)
1124
1214
  return schemas
1125
1215
  }
1126
1216
 
1127
1217
  /**
1128
- * Reconcile a deferred collection's per-record directory with the records it
1129
- * should hold this run — delete the `<slug>.json` files that are no longer
1130
- * backed by a record.
1218
+ * ⭐ A QUERY NAMED FOR A DATA KEY HOLDS RECORDS OF THE KEY'S TYPE (ruled 2026-09-25 [Diego],
1219
+ * `uwx/data-key-types.js`). `articles:` with no `schema:` names `@/articles`; when no data schema
1220
+ * has that name while the foundation's section types declare `data: { articles: '@/post' }`, its
1221
+ * records are `@/post` records — a push sends them as such, and they are delivered as such here,
1222
+ * where they still live and are keyed by the name (`records/articles/`). Never for a schema the
1223
+ * query asked for explicitly: that one is the author's, and does not resolve. ⛔ Until 2026-09-27
1224
+ * this lane compiled them as if they had no data schema, while a push typed them.
1225
+ *
1226
+ * @param {string} siteDir
1227
+ * @param {Object} queriesConfig
1228
+ * @param {Object} schemas - resolved data schemas by ref; a typed query's ref is added to it
1229
+ */
1230
+ async function typeByDataKey(siteDir, queriesConfig, schemas) {
1231
+ const untyped = Object.entries(queriesConfig).filter(
1232
+ ([name, c]) => c && typeof c === 'object' && c.url === undefined && c.schema === `@/${name}` && !schemas[c.schema]
1233
+ )
1234
+ if (untyped.length === 0) return
1235
+ // `config.queries` carries no trace of whether its author wrote the schema; the declarations do.
1236
+ let declarations = {}
1237
+ try {
1238
+ declarations = (await resolveQueriesConfig(siteDir)).declarations || {}
1239
+ } catch {
1240
+ // a declaration the build refuses is reported where the build reads it
1241
+ }
1242
+ const pending = untyped.filter(([name]) => declarations[name]?.schemaExplicit !== true)
1243
+ if (pending.length === 0) return
1244
+ const keyTypes = dataKeyTypes(await foundationSections(siteDir))
1245
+ const wanted = new Map()
1246
+ for (const [name, c] of pending) {
1247
+ const type = keyTypes.get(name)
1248
+ if (type) wanted.set(c.schema, type)
1249
+ }
1250
+ if (wanted.size === 0) return
1251
+ const { schemas: types } = await resolveRecordSchemas(siteDir, wanted.values())
1252
+ for (const [ref, type] of wanted) if (types[type]) schemas[ref] = types[type]
1253
+ }
1254
+
1255
+ /**
1256
+ * Reconcile a query's per-record directory with the records it should hold this
1257
+ * run — delete the `<$name>.json` files that are no longer backed by a record.
1131
1258
  *
1132
1259
  * Why this is not optional. `public/data/` is a persistent, normally-committed
1133
1260
  * directory, so anything written there survives until something removes it.
@@ -1139,11 +1266,9 @@ async function querySchemas(siteDir, queriesConfig) {
1139
1266
  * that was public a moment ago.
1140
1267
  *
1141
1268
  * `public/data/` is the build's output directory and nothing else — authors
1142
- * provide structured data through `collections/`, which is the only supported
1269
+ * provide structured data through `records/`, which is the only supported
1143
1270
  * way. So `<name>/` is entirely ours and the reconciliation is total: anything
1144
- * in it that this run did not write is stale by definition. `expected` is
1145
- * empty when a collection stops declaring `deferred:`, which correctly clears
1146
- * a directory that will otherwise never be written again.
1271
+ * in it that this run did not write is stale by definition.
1147
1272
  *
1148
1273
  * NOT covered: a collection removed from `site.yml` entirely. There is no
1149
1274
  * declaration left to reconcile against, so pruning it would mean the build
@@ -1204,62 +1329,44 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
1204
1329
  for (const [name, items] of Object.entries(byQuery)) {
1205
1330
  const rawConfig = queriesConfig?.[name]
1206
1331
  const parsed = rawConfig ? parseQueryConfig(name, rawConfig) : null
1207
- const deferred = parsed?.deferred
1208
-
1209
- if (deferred && deferred.length > 0) {
1210
- // `deferred:` is set — emit two payloads:
1211
- // 1. The cascade JSON at /data/<name>.json with deferred fields stripped.
1212
- // This is what `query: <name>` declarations deliver everywhere.
1213
- // 2. Per-record full files at /data/<name>/<slug>.json with every field.
1214
- // Dynamic-route singular fetches and kit's useWholeRecord read these.
1215
- const recordsDir = join(dataDir, name)
1216
- await mkdir(recordsDir, { recursive: true })
1217
-
1218
- const written = new Set()
1219
- for (const item of items) {
1220
- if (!item || typeof item !== 'object' || !item.slug) continue
1221
- const filename = `${item.slug}.json`
1222
- await writeFile(join(recordsDir, filename), JSON.stringify(item, null, 2))
1223
- written.add(filename)
1224
- }
1225
- const perRecordCount = written.size
1226
- const pruned = await pruneOrphanedRecords(dataDir, name, written)
1227
-
1228
- const stripped = items.map((item) => {
1229
- if (!item || typeof item !== 'object') return item
1230
- const out = { ...item }
1231
- for (const field of deferred) delete out[field]
1232
- return out
1233
- })
1234
- const cascadePath = join(dataDir, `${name}.json`)
1235
- await writeFile(cascadePath, JSON.stringify(stripped, null, 2))
1332
+
1333
+ // ⭐ TWO PAYLOADS FOR EVERY QUERY (ruled 2026-09-27 [Diego]):
1334
+ // 1. Each record WHOLE, as stored, at /data/<name>/<$name>.json — a key per section, the
1335
+ // brief's included — what a whole question answers: a parametric page's record for a
1336
+ // component that declares `'@x/y/*'`, and kit's `useWholeRecord`.
1337
+ // 2. The list at /data/<name>.json — each record's BRIEF, the brief's fields at the top —
1338
+ // what `query: <name>` delivers everywhere else. ⛔ `deferred:`, which left fields of the
1339
+ // brief out of it, was retired the same day [Diego].
1340
+ // ⛔ Until then only a `deferred:` query had per-record files, and they held the merged view:
1341
+ // the brief's fields at the top beside the other sections' names.
1342
+ const recordsDir = join(dataDir, name)
1343
+ await mkdir(recordsDir, { recursive: true })
1344
+ const written = new Set()
1345
+ for (const item of items) {
1346
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue
1347
+ const handle = item.$name
1348
+ if (handle === undefined || handle === null || handle === '') continue
1349
+ const filename = `${handle}.json`
1350
+ await writeFile(join(recordsDir, filename), JSON.stringify(item[WHOLE_RECORD] ?? item, null, 2))
1351
+ written.add(filename)
1352
+ }
1353
+ const pruned = await pruneOrphanedRecords(dataDir, name, written)
1354
+
1355
+ const list = items.map((item) =>
1356
+ item && typeof item === 'object' && !Array.isArray(item) ? { ...(item[BRIEF_RECORD] ?? item) } : item
1357
+ )
1358
+ const cascadePath = join(dataDir, `${name}.json`)
1359
+ await writeFile(cascadePath, JSON.stringify(list, null, 2))
1360
+ console.log(
1361
+ `[query-processor] Generated ${cascadePath} (${items.length} items) + ${written.size} per-record files`
1362
+ )
1363
+ if (pruned.length > 0) {
1364
+ // A deletion is always worth naming. These files were public a moment
1365
+ // ago, so "which ones went" is the question an author will have.
1236
1366
  console.log(
1237
- `[query-processor] Generated ${cascadePath} (${items.length} items, ` +
1238
- `deferred: [${deferred.join(', ')}]) + ${perRecordCount} per-record files`
1367
+ `[query-processor] Removed ${pruned.length} stale per-record ` +
1368
+ `file(s) from ${recordsDir}: ${pruned.join(', ')}`
1239
1369
  )
1240
- if (pruned.length > 0) {
1241
- // A deletion is always worth naming. These files were public a moment
1242
- // ago, so "which ones went" is the question an author will have.
1243
- console.log(
1244
- `[query-processor] Removed ${pruned.length} stale per-record ` +
1245
- `file(s) from ${recordsDir}: ${pruned.join(', ')}`
1246
- )
1247
- }
1248
- } else {
1249
- const filepath = join(dataDir, `${name}.json`)
1250
- await writeFile(filepath, JSON.stringify(items, null, 2))
1251
- console.log(`[query-processor] Generated ${filepath} (${items.length} items)`)
1252
-
1253
- // This collection is not deferred, so it has no per-record files. If it
1254
- // used to, the directory is still there and will never be written again
1255
- // — every file in it is stale. Same reconciliation, empty expected set.
1256
- const pruned = await pruneOrphanedRecords(dataDir, name, new Set())
1257
- if (pruned.length > 0) {
1258
- console.log(
1259
- `[query-processor] Removed ${pruned.length} per-record file(s) ` +
1260
- `from ${join(dataDir, name)} — "${name}" no longer declares deferred:`
1261
- )
1262
- }
1263
1370
  }
1264
1371
  }
1265
1372
  }