@uniweb/build 0.64.2 → 0.66.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, BRANCH_KEY } 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
@@ -724,8 +760,8 @@ export function flatFormRefusal(where, ref, schema, misplaced) {
724
760
  * Every source file in a collection, as paths relative to the collection root —
725
761
  * `hello.md`, `2024/spring.md`, `2024/q1/notes.yml`.
726
762
  *
727
- * Nesting is how an author gives a collection an internal structure, and it is
728
- * what the `path` field and the `under` predicate address. Before this walk the
763
+ * Nesting is how an author organizes the files; the folder branch a record sits in, which a
764
+ * query's `scope:` reads, is `records/folder.yml`'s to say (`records-config.js`). Before this walk the
729
765
  * scan was a flat `readdir`, so a record in a subdirectory was not ignored with
730
766
  * a warning — it was invisible, and the site simply rendered without it.
731
767
  *
@@ -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)
767
- const where = item.path ? `${item.path}/` : ''
768
- if (seen.has(slug)) {
801
+ if (!item || item.$name === undefined) continue
802
+ const name = String(item.$name)
803
+ const where = item[BRANCH_KEY] ? `${item[BRANCH_KEY]}/` : ''
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
  }
@@ -891,11 +927,19 @@ function pickBrief(record, keys) {
891
927
  return out
892
928
  }
893
929
  for (const [key, value] of Object.entries(record)) {
894
- if (!key.startsWith('$') && !['slug', 'path', 'excerpt', 'image', 'draft'].includes(key)) out[key] = value
930
+ if (!key.startsWith('$') && !['slug', 'excerpt', 'image', 'draft'].includes(key)) out[key] = value
895
931
  }
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
@@ -922,10 +966,17 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
922
966
  // BibTeX → CSL-JSON bibliography items).
923
967
  let items = await readPooledRecords(pooled, config, siteDir, recordsRoot, basePath)
924
968
 
925
- // ⭐ `path` IS THE FOLDER `folder.yml` PLACED THE RECORD IN — `''` at the top —
969
+ // ⭐ `$branch` IS THE FOLDER `folder.yml` PLACED THE RECORD IN — `''` at the top —
926
970
  // and it is the whole reason folders exist: `scope: archive` is how a query asks
927
971
  // for a slice. Structure is query scope, not navigation.
928
972
  //
973
+ // ⛔ THE COMPILED FILE'S OWN KEY, NOT THE RECORD'S (ruled 2026-09-27 [Diego]): the
974
+ // browser evaluates `scope` over this file, so the file holds each record's branch, and
975
+ // no answer carries it — `@uniweb/core`'s `evaluateQuery` reads it and drops it, as a
976
+ // records service answers no branch. ⛔ Until then it was `path`: it reached every
977
+ // component, wrote over an authored field named `path`, and made a `[...path]` record's
978
+ // link `<branch>/<name>`.
979
+ //
929
980
  // ⛔ THIS WAS HARDCODED TO `''` FOR A WHILE, AND THE COMMENT SAID "until the
930
981
  // folder producer lands". It landed, and this was not revisited — so every
931
982
  // folder slice matched NOTHING on the delivery lane, silently, which is the one
@@ -935,13 +986,13 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
935
986
  // ⚠️ It stays a SCALAR. `@uniweb/core`'s `withinScope` matches strings only, so
936
987
  // an array would match nothing — one placement per record is the ruling.
937
988
  items = items.map((result, i) => {
938
- const path = pooled[i] ? (config.placements?.get(pooled[i].id)?.path ?? '') : ''
939
- if (Array.isArray(result)) return result.map((item) => item && { ...item, path })
940
- return result && { ...result, path }
989
+ const branch = pooled[i] ? (config.placements?.get(pooled[i].id)?.path ?? '') : ''
990
+ if (Array.isArray(result)) return result.map((item) => item && { ...item, [BRANCH_KEY]: branch })
991
+ return result && { ...result, [BRANCH_KEY]: branch }
941
992
  })
942
993
 
943
- // Flatten one level: array-form YAML/JSON files and every .bib file
944
- // contribute their entries individually.
994
+ // Flatten one level: a .bib file contributes its entries individually. Every other
995
+ // record file is one record (`record-file.js`).
945
996
  items = items.flat()
946
997
 
947
998
  // Filter out nulls (drafts this run withholds)
@@ -949,19 +1000,12 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
949
1000
 
950
1001
  // ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
951
1002
  // 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
-
1003
+ // serves: the name its file gives it — the file's stem, or a BibTeX entry's cite key
1004
+ // (a file's own `slug:` renamed it, and a list file held several, until 2026-09-27;
1005
+ // both are refused now, `record-file.js`) — exactly what our sync
1006
+ // sends as the entry's name (`uwx/entity-source.js`). `deliverRecord` and
1007
+ // `processBibtexItem` set it. ⛔ Until 2026-09-27 the delivered record carried `slug`
1008
+ // beside it, and this filled `$name` from it; no record carries `slug` now.
965
1009
  warnDuplicateSlugs(items, config.name)
966
1010
 
967
1011
  // ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where, then sort. Two
@@ -982,16 +1026,27 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
982
1026
  //
983
1027
  // ⛔ `scope` is NEVER baked, fixed or routed — the runtime applies it, as the
984
1028
  // records service does, so a routed `scope: :dir` binds per page.
1029
+ //
1030
+ // ⭐ A PATH READS THE RECORD AS STORED, as the records service reads it (ruled 2026-09-27
1031
+ // [Diego]): `details` is the brief's field and `details.pages` the
1032
+ // section's, even where the two share a name — which the merged view the records are carried in
1033
+ // cannot hold apart. A record with no data schema is read as it is.
985
1034
  const fixed = withoutRouteVariables({ where: config.where })
986
- if (fixed.where) {
987
- items = applyWhere(items, fixed.where)
1035
+ const asked = config.dataSchema
1036
+ ? mapQueryPaths({ where: fixed.where, sort: config.sort }, (path) => storedPath(config.dataSchema, path))
1037
+ : { where: fixed.where, sort: config.sort }
1038
+ const read = (item) => (item && item[WHOLE_RECORD]) || item
1039
+ if (asked.where) {
1040
+ const kept = new Set(applyWhere(items.map(read), asked.where))
1041
+ items = items.filter((item) => kept.has(read(item)))
988
1042
  }
989
1043
 
990
1044
  // The query's `sort` orders the file; the runtime applies whichever sort wins —
991
1045
  // a binding's own, else this one — so the order here only spares it the work.
992
1046
  // 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 })
1047
+ if (asked.sort) {
1048
+ const itemOf = new Map(items.map((item) => [read(item), item]))
1049
+ items = applySort([...itemOf.keys()], asked.sort, { locale }).map((held) => itemOf.get(held))
995
1050
  }
996
1051
 
997
1052
  // ⛔ `limit` IS NEVER BAKED. A query's `limit` is part of its set (ruled 2026-09-14
@@ -1004,9 +1059,44 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
1004
1059
  // file — before a page's `scope` or routed clauses, which the runtime has applied
1005
1060
  // since 2026-09-11, could select anything from it.
1006
1061
 
1062
+ // ⭐ EACH RECORD'S BRIEF — what its list holds: the brief's fields at the top, beside the record's
1063
+ // own keys, what the build derives (`excerpt`, `image`), and the branch the list holds for `scope`
1064
+ // (`$branch`, which no answer carries). A record of a schema with no
1065
+ // brief, or of one with a single section, is its brief as it is.
1066
+ const briefOf = briefBuilder(config.dataSchema)
1067
+ if (briefOf) {
1068
+ items = items.map((item) =>
1069
+ item && typeof item === 'object' && !Array.isArray(item) ? { ...item, [BRIEF_RECORD]: briefOf(item) } : item
1070
+ )
1071
+ }
1072
+
1007
1073
  return items
1008
1074
  }
1009
1075
 
1076
+ /**
1077
+ * A record's BRIEF, built from the record AS STORED — its brief section's fields — beside the keys of
1078
+ * the merged view that are neither a section nor a brief field. ⛔ Not the merged view less the other
1079
+ * sections: sections are namespaces (ruled 2026-09-26 [Diego]), so a brief field may share a
1080
+ * section's name, and the merged view holds only one of the two. Null when a list is the record as
1081
+ * it is.
1082
+ */
1083
+ function briefBuilder(schema) {
1084
+ const layout = schema ? recordLayout(schema) : null
1085
+ if (!layout || schema.fields || layout.flat || !layout.brief) return null
1086
+ const sections = new Set(layout.sections.map(([name]) => name))
1087
+ const fields = new Set(Object.keys(briefFieldMap(schema) || {}))
1088
+ return (item) => {
1089
+ const stored = item[WHOLE_RECORD]
1090
+ const held = stored?.[layout.brief]
1091
+ const brief = held && typeof held === 'object' && !Array.isArray(held) ? held : null
1092
+ const out = {}
1093
+ for (const [key, value] of Object.entries(item)) {
1094
+ if (!sections.has(key) && !(brief && fields.has(key))) out[key] = value
1095
+ }
1096
+ return brief ? { ...out, ...brief } : out
1097
+ }
1098
+ }
1099
+
1010
1100
  /**
1011
1101
  * Compile every query over the site's records
1012
1102
  *
@@ -1045,7 +1135,7 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1045
1135
  }
1046
1136
 
1047
1137
  // ⭐ EVERY FILE IN `records/` IS A RECORD (ruled 2026-09-21 [Diego]), so every one
1048
- // is compiled; `records/folder.yml` only says which folder each sits in (`path`),
1138
+ // is compiled; `records/folder.yml` only says which folder each sits in (`$branch`),
1049
1139
  // which is what a query's `scope` reads. ⛔ Until 2026-09-21 `records.yml`, at the
1050
1140
  // site root, listed the records, and an unlisted file was left out of
1051
1141
  // `/data/<name>.json` here.
@@ -1063,6 +1153,10 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1063
1153
  const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
1064
1154
  for (const e of folder.errors) console.error(`[query-processor] ${e}`)
1065
1155
  for (const w of folder.warnings) console.warn(`[query-processor] ${w}`)
1156
+ // ⭐ A QUESTION WITH NO `sort` ANSWERS IN FOLDER-TREE ORDER (ruled 2026-09-27 [Diego]) — the
1157
+ // order a backend's records service answers in, over the tree a push places there. ⛔ Until then
1158
+ // this lane answered in the order the directory walk found the files.
1159
+ const folderRank = new Map(folderTreeOrder(folder.nodes).map((id, i) => [id, i]))
1066
1160
 
1067
1161
  const poolBySchema = groupPoolBySchema(pool.entities)
1068
1162
  const recordsRoot = resolve(siteDir, pool.dir)
@@ -1083,6 +1177,7 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1083
1177
  const parsed = parseQueryConfig(name, config)
1084
1178
  parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
1085
1179
  parsed.placements = folder.placements
1180
+ parsed.folderRank = folderRank
1086
1181
  parsed.includeDrafts = includeDrafts
1087
1182
  parsed.dataSchema = (parsed.schema && dataSchemas[parsed.schema]) || null
1088
1183
  parsed.references = references
@@ -1123,13 +1218,51 @@ async function querySchemas(siteDir, queriesConfig) {
1123
1218
  `their files hold them rather than as a component receives them: ${message}`
1124
1219
  )
1125
1220
  }
1221
+ await typeByDataKey(siteDir, queriesConfig, schemas)
1126
1222
  return schemas
1127
1223
  }
1128
1224
 
1129
1225
  /**
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.
1226
+ * ⭐ A QUERY NAMED FOR A DATA KEY HOLDS RECORDS OF THE KEY'S TYPE (ruled 2026-09-25 [Diego],
1227
+ * `uwx/data-key-types.js`). `articles:` with no `schema:` names `@/articles`; when no data schema
1228
+ * has that name while the foundation's section types declare `data: { articles: '@/post' }`, its
1229
+ * records are `@/post` records — a push sends them as such, and they are delivered as such here,
1230
+ * where they still live and are keyed by the name (`records/articles/`). Never for a schema the
1231
+ * query asked for explicitly: that one is the author's, and does not resolve. ⛔ Until 2026-09-27
1232
+ * this lane compiled them as if they had no data schema, while a push typed them.
1233
+ *
1234
+ * @param {string} siteDir
1235
+ * @param {Object} queriesConfig
1236
+ * @param {Object} schemas - resolved data schemas by ref; a typed query's ref is added to it
1237
+ */
1238
+ async function typeByDataKey(siteDir, queriesConfig, schemas) {
1239
+ const untyped = Object.entries(queriesConfig).filter(
1240
+ ([name, c]) => c && typeof c === 'object' && c.url === undefined && c.schema === `@/${name}` && !schemas[c.schema]
1241
+ )
1242
+ if (untyped.length === 0) return
1243
+ // `config.queries` carries no trace of whether its author wrote the schema; the declarations do.
1244
+ let declarations = {}
1245
+ try {
1246
+ declarations = (await resolveQueriesConfig(siteDir)).declarations || {}
1247
+ } catch {
1248
+ // a declaration the build refuses is reported where the build reads it
1249
+ }
1250
+ const pending = untyped.filter(([name]) => declarations[name]?.schemaExplicit !== true)
1251
+ if (pending.length === 0) return
1252
+ const keyTypes = dataKeyTypes(await foundationSections(siteDir))
1253
+ const wanted = new Map()
1254
+ for (const [name, c] of pending) {
1255
+ const type = keyTypes.get(name)
1256
+ if (type) wanted.set(c.schema, type)
1257
+ }
1258
+ if (wanted.size === 0) return
1259
+ const { schemas: types } = await resolveRecordSchemas(siteDir, wanted.values())
1260
+ for (const [ref, type] of wanted) if (types[type]) schemas[ref] = types[type]
1261
+ }
1262
+
1263
+ /**
1264
+ * Reconcile a query's per-record directory with the records it should hold this
1265
+ * run — delete the `<$name>.json` files that are no longer backed by a record.
1133
1266
  *
1134
1267
  * Why this is not optional. `public/data/` is a persistent, normally-committed
1135
1268
  * directory, so anything written there survives until something removes it.
@@ -1141,11 +1274,9 @@ async function querySchemas(siteDir, queriesConfig) {
1141
1274
  * that was public a moment ago.
1142
1275
  *
1143
1276
  * `public/data/` is the build's output directory and nothing else — authors
1144
- * provide structured data through `collections/`, which is the only supported
1277
+ * provide structured data through `records/`, which is the only supported
1145
1278
  * 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.
1279
+ * in it that this run did not write is stale by definition.
1149
1280
  *
1150
1281
  * NOT covered: a collection removed from `site.yml` entirely. There is no
1151
1282
  * declaration left to reconcile against, so pruning it would mean the build
@@ -1206,62 +1337,46 @@ export async function writeQueryFiles(siteDir, byQuery, queriesConfig = null) {
1206
1337
  for (const [name, items] of Object.entries(byQuery)) {
1207
1338
  const rawConfig = queriesConfig?.[name]
1208
1339
  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))
1340
+
1341
+ // ⭐ TWO PAYLOADS FOR EVERY QUERY (ruled 2026-09-27 [Diego]):
1342
+ // 1. Each record WHOLE, as stored, at /data/<name>/<$name>.json — a key per section, the
1343
+ // brief's included — what a whole question answers: a parametric page's record for a
1344
+ // component that declares `'@x/y/*'`, and kit's `useWholeRecord`.
1345
+ // 2. The list at /data/<name>.json — each record's BRIEF, the brief's fields at the top —
1346
+ // what `query: <name>` delivers everywhere else. ⛔ `deferred:`, which left fields of the
1347
+ // brief out of it, was retired the same day [Diego].
1348
+ // ⛔ Until then only a `deferred:` query had per-record files, and they held the merged view:
1349
+ // the brief's fields at the top beside the other sections' names.
1350
+ const recordsDir = join(dataDir, name)
1351
+ await mkdir(recordsDir, { recursive: true })
1352
+ const written = new Set()
1353
+ for (const item of items) {
1354
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue
1355
+ const handle = item.$name
1356
+ if (handle === undefined || handle === null || handle === '') continue
1357
+ const filename = `${handle}.json`
1358
+ // The record's own file answers it whole, so it holds no branch (the list does, for `scope`).
1359
+ const { [BRANCH_KEY]: _branch, ...own } = item
1360
+ await writeFile(join(recordsDir, filename), JSON.stringify(item[WHOLE_RECORD] ?? own, null, 2))
1361
+ written.add(filename)
1362
+ }
1363
+ const pruned = await pruneOrphanedRecords(dataDir, name, written)
1364
+
1365
+ const list = items.map((item) =>
1366
+ item && typeof item === 'object' && !Array.isArray(item) ? { ...(item[BRIEF_RECORD] ?? item) } : item
1367
+ )
1368
+ const cascadePath = join(dataDir, `${name}.json`)
1369
+ await writeFile(cascadePath, JSON.stringify(list, null, 2))
1370
+ console.log(
1371
+ `[query-processor] Generated ${cascadePath} (${items.length} items) + ${written.size} per-record files`
1372
+ )
1373
+ if (pruned.length > 0) {
1374
+ // A deletion is always worth naming. These files were public a moment
1375
+ // ago, so "which ones went" is the question an author will have.
1238
1376
  console.log(
1239
- `[query-processor] Generated ${cascadePath} (${items.length} items, ` +
1240
- `deferred: [${deferred.join(', ')}]) + ${perRecordCount} per-record files`
1377
+ `[query-processor] Removed ${pruned.length} stale per-record ` +
1378
+ `file(s) from ${recordsDir}: ${pruned.join(', ')}`
1241
1379
  )
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
1380
  }
1266
1381
  }
1267
1382
  }