@uniweb/build 0.64.2 → 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,7 +590,7 @@ 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
593
+ * field (`body.content`, for `@std/article`), the markdown source for a markup
609
594
  * `text` one. A component is written against one shape, so both lanes deliver the same
610
595
  * one — ruled 2026-09-24 [Diego] as a requirement that the shape be one AND a good one,
611
596
  * not as an instruction to adopt the host's. ⚠️ It was built by adopting the host's as it
@@ -627,14 +612,19 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
627
612
  * ⛔ A record written in the retired flat form (`misplacedFields`) STOPS THE BUILD, as it
628
613
  * stops a push: which section a key belongs in is not a guess to make.
629
614
  *
630
- * @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
631
616
  * @param {Object} config - the parsed query (`dataSchema`, `excerpt`)
632
- * @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
633
618
  * @param {{ doc: Object, markdown: string }|null} [body] - a markdown record's body
634
619
  * @returns {*} the delivered record (anything but a record, as it came)
635
620
  */
636
- function deliverRecord(record, config, where, body = null) {
637
- 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
638
628
  const schema = config.dataSchema || null
639
629
  let out
640
630
  if (!schema) {
@@ -657,12 +647,58 @@ function deliverRecord(record, config, where, body = null) {
657
647
  out.image = out.image || extractFirstImage(body.doc)
658
648
  }
659
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
+ }
660
658
  }
661
- const handle = record.slug
662
- if (handle !== undefined && handle !== null && handle !== '') out.$name = String(handle)
663
659
  return out
664
660
  }
665
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
+
666
702
  /**
667
703
  * Put a markdown body where the delivered record carries its schema's content body
668
704
  * field (`contentBodyField`) — unless the record already states that field, or the body
@@ -762,18 +798,18 @@ async function collectSourceFiles(dir, rel = '') {
762
798
  function warnDuplicateSlugs(items, queryName) {
763
799
  const seen = new Map()
764
800
  for (const item of items) {
765
- if (!item || item.slug === undefined) continue
766
- const slug = String(item.slug)
801
+ if (!item || item.$name === undefined) continue
802
+ const name = String(item.$name)
767
803
  const where = item.path ? `${item.path}/` : ''
768
- if (seen.has(slug)) {
804
+ if (seen.has(name)) {
769
805
  console.warn(
770
- `[query-processor] Query "${queryName}" has more than one record with ` +
771
- `slug "${slug}" (${seen.get(slug)}${slug}, ${where}${slug}). Its detail route and ` +
772
- `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.`
773
809
  )
774
810
  continue
775
811
  }
776
- seen.set(slug, where)
812
+ seen.set(name, where)
777
813
  }
778
814
  }
779
815
 
@@ -851,7 +887,7 @@ function referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, ba
851
887
  if (!targets.has(ref)) targets.set(ref, await load(ref))
852
888
  }
853
889
  },
854
- deliver(schema, record, where) {
890
+ deliver(schema, record, where, { file = false } = {}) {
855
891
  return mapReferences(
856
892
  schema,
857
893
  record,
@@ -865,7 +901,7 @@ function referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, ba
865
901
  }
866
902
  return value
867
903
  },
868
- { delivered: true }
904
+ { delivered: !file }
869
905
  )
870
906
  },
871
907
  }
@@ -896,6 +932,14 @@ function pickBrief(record, keys) {
896
932
  return out
897
933
  }
898
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
+
899
943
  /**
900
944
  * Collect and process all of a query's records
901
945
  *
@@ -909,7 +953,7 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
909
953
  // sync lane makes, from the same reader, so the two lanes cannot disagree
910
954
  // about which files are a query's records. They used to: this one recursed
911
955
  // into a collection directory and sync did not.
912
- const pooled = config.poolEntities || []
956
+ const pooled = inFolderOrder(config.poolEntities || [], config.folderRank)
913
957
  if (pooled.length === 0) return []
914
958
 
915
959
  // The records this query's references name, read before its own — delivering one
@@ -940,8 +984,8 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
940
984
  return result && { ...result, path }
941
985
  })
942
986
 
943
- // Flatten one level: array-form YAML/JSON files and every .bib file
944
- // 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`).
945
989
  items = items.flat()
946
990
 
947
991
  // Filter out nulls (drafts this run withholds)
@@ -949,19 +993,12 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
949
993
 
950
994
  // ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
951
995
  // field a `[slug]` or `[...path]` page matches, and the one the records service
952
- // serves. It is the record's FINAL slug — a frontmatter `slug:` (which wins over the
953
- // filename), a BibTeX cite key and an array-form file's own `slug` all count — exactly
954
- // what our sync sends as the entry's name (`uwx/entity-source.js`). `deliverRecord`
955
- // sets it for a markdown, YAML or JSON record, before the brief is lifted (a brief's
956
- // own `slug` FIELD must not become the handle); a BibTeX entry gets it here. `slug`
957
- // stays: foundations and templates read it.
958
- items = items.map((item) => (
959
- item && typeof item === 'object' && item.$name === undefined &&
960
- item.slug !== undefined && item.slug !== null && item.slug !== ''
961
- ? { ...item, $name: String(item.slug) }
962
- : item
963
- ))
964
-
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.
965
1002
  warnDuplicateSlugs(items, config.name)
966
1003
 
967
1004
  // ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where, then sort. Two
@@ -982,16 +1019,27 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
982
1019
  //
983
1020
  // ⛔ `scope` is NEVER baked, fixed or routed — the runtime applies it, as the
984
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.
985
1027
  const fixed = withoutRouteVariables({ where: config.where })
986
- if (fixed.where) {
987
- 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)))
988
1035
  }
989
1036
 
990
1037
  // The query's `sort` orders the file; the runtime applies whichever sort wins —
991
1038
  // a binding's own, else this one — so the order here only spares it the work.
992
1039
  // Texts collate in the site's default language; each page re-sorts in its own.
993
- if (config.sort) {
994
- 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))
995
1043
  }
996
1044
 
997
1045
  // ⛔ `limit` IS NEVER BAKED. A query's `limit` is part of its set (ruled 2026-09-14
@@ -1004,9 +1052,43 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
1004
1052
  // file — before a page's `scope` or routed clauses, which the runtime has applied
1005
1053
  // since 2026-09-11, could select anything from it.
1006
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
+
1007
1065
  return items
1008
1066
  }
1009
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
+
1010
1092
  /**
1011
1093
  * Compile every query over the site's records
1012
1094
  *
@@ -1063,6 +1145,10 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1063
1145
  const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
1064
1146
  for (const e of folder.errors) console.error(`[query-processor] ${e}`)
1065
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]))
1066
1152
 
1067
1153
  const poolBySchema = groupPoolBySchema(pool.entities)
1068
1154
  const recordsRoot = resolve(siteDir, pool.dir)
@@ -1083,6 +1169,7 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1083
1169
  const parsed = parseQueryConfig(name, config)
1084
1170
  parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
1085
1171
  parsed.placements = folder.placements
1172
+ parsed.folderRank = folderRank
1086
1173
  parsed.includeDrafts = includeDrafts
1087
1174
  parsed.dataSchema = (parsed.schema && dataSchemas[parsed.schema]) || null
1088
1175
  parsed.references = references
@@ -1123,13 +1210,51 @@ async function querySchemas(siteDir, queriesConfig) {
1123
1210
  `their files hold them rather than as a component receives them: ${message}`
1124
1211
  )
1125
1212
  }
1213
+ await typeByDataKey(siteDir, queriesConfig, schemas)
1126
1214
  return schemas
1127
1215
  }
1128
1216
 
1129
1217
  /**
1130
- * Reconcile a deferred collection's per-record directory with the records it
1131
- * should hold this run — delete the `<slug>.json` files that are no longer
1132
- * 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.
1133
1258
  *
1134
1259
  * Why this is not optional. `public/data/` is a persistent, normally-committed
1135
1260
  * directory, so anything written there survives until something removes it.
@@ -1141,11 +1266,9 @@ async function querySchemas(siteDir, queriesConfig) {
1141
1266
  * that was public a moment ago.
1142
1267
  *
1143
1268
  * `public/data/` is the build's output directory and nothing else — authors
1144
- * provide structured data through `collections/`, which is the only supported
1269
+ * provide structured data through `records/`, which is the only supported
1145
1270
  * way. So `<name>/` is entirely ours and the reconciliation is total: anything
1146
- * in it that this run did not write is stale by definition. `expected` is
1147
- * empty when a collection stops declaring `deferred:`, which correctly clears
1148
- * a directory that will otherwise never be written again.
1271
+ * in it that this run did not write is stale by definition.
1149
1272
  *
1150
1273
  * NOT covered: a collection removed from `site.yml` entirely. There is no
1151
1274
  * declaration left to reconcile against, so pruning it would mean the build
@@ -1206,62 +1329,44 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
1206
1329
  for (const [name, items] of Object.entries(byQuery)) {
1207
1330
  const rawConfig = queriesConfig?.[name]
1208
1331
  const parsed = rawConfig ? parseQueryConfig(name, rawConfig) : null
1209
- const deferred = parsed?.deferred
1210
-
1211
- if (deferred && deferred.length > 0) {
1212
- // `deferred:` is set — emit two payloads:
1213
- // 1. The cascade JSON at /data/<name>.json with deferred fields stripped.
1214
- // This is what `query: <name>` declarations deliver everywhere.
1215
- // 2. Per-record full files at /data/<name>/<slug>.json with every field.
1216
- // Dynamic-route singular fetches and kit's useWholeRecord read these.
1217
- const recordsDir = join(dataDir, name)
1218
- await mkdir(recordsDir, { recursive: true })
1219
-
1220
- const written = new Set()
1221
- for (const item of items) {
1222
- if (!item || typeof item !== 'object' || !item.slug) continue
1223
- const filename = `${item.slug}.json`
1224
- await writeFile(join(recordsDir, filename), JSON.stringify(item, null, 2))
1225
- written.add(filename)
1226
- }
1227
- const perRecordCount = written.size
1228
- const pruned = await pruneOrphanedRecords(dataDir, name, written)
1229
-
1230
- const stripped = items.map((item) => {
1231
- if (!item || typeof item !== 'object') return item
1232
- const out = { ...item }
1233
- for (const field of deferred) delete out[field]
1234
- return out
1235
- })
1236
- const cascadePath = join(dataDir, `${name}.json`)
1237
- 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.
1238
1366
  console.log(
1239
- `[query-processor] Generated ${cascadePath} (${items.length} items, ` +
1240
- `deferred: [${deferred.join(', ')}]) + ${perRecordCount} per-record files`
1367
+ `[query-processor] Removed ${pruned.length} stale per-record ` +
1368
+ `file(s) from ${recordsDir}: ${pruned.join(', ')}`
1241
1369
  )
1242
- if (pruned.length > 0) {
1243
- // A deletion is always worth naming. These files were public a moment
1244
- // ago, so "which ones went" is the question an author will have.
1245
- console.log(
1246
- `[query-processor] Removed ${pruned.length} stale per-record ` +
1247
- `file(s) from ${recordsDir}: ${pruned.join(', ')}`
1248
- )
1249
- }
1250
- } else {
1251
- const filepath = join(dataDir, `${name}.json`)
1252
- await writeFile(filepath, JSON.stringify(items, null, 2))
1253
- console.log(`[query-processor] Generated ${filepath} (${items.length} items)`)
1254
-
1255
- // This collection is not deferred, so it has no per-record files. If it
1256
- // used to, the directory is still there and will never be written again
1257
- // — every file in it is stale. Same reconciliation, empty expected set.
1258
- const pruned = await pruneOrphanedRecords(dataDir, name, new Set())
1259
- if (pruned.length > 0) {
1260
- console.log(
1261
- `[query-processor] Removed ${pruned.length} per-record file(s) ` +
1262
- `from ${join(dataDir, name)} — "${name}" no longer declares deferred:`
1263
- )
1264
- }
1265
1370
  }
1266
1371
  }
1267
1372
  }