@uniweb/build 0.61.0 → 0.62.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.
@@ -30,8 +30,10 @@
30
30
 
31
31
  import { join } from 'node:path'
32
32
  import { existsSync, readFileSync } from 'node:fs'
33
- import { briefFields, flatRecordFields } from '@uniweb/schemas/conform'
33
+ import { recordLayout } from '@uniweb/schemas/conform'
34
34
  import { detectFoundationType } from './foundation-ref.js'
35
+ import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
36
+ import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
35
37
  import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
36
38
  import { readFile } from 'node:fs/promises'
37
39
  import yaml from 'js-yaml'
@@ -325,13 +327,17 @@ export function toConfigQueries(declarations) {
325
327
  * schema should not have to say it twice, in a second vocabulary, with nothing
326
328
  * checking the two against each other.
327
329
  *
328
- * ⇒ `deferred` = the schema's flat-record fields MINUS its brief fields.
330
+ * ⇒ `deferred` = every top-level section of the schema other than its brief — the keys
331
+ * a delivered record holds them under (`article_body` for `@std/article`), which is
332
+ * exactly what a host's records service leaves out of a list and adds for `whole`.
333
+ * ⛔ It was the flat form's fields minus the brief's until 2026-09-24; a delivered
334
+ * record carries those under their section, so stripping them by name stripped nothing.
329
335
  *
330
336
  * Derived from the SCHEMA, never from a record. That is what keeps the
331
337
  * build-derived keys safe without a reserved list: `slug`, `route`, `path`,
332
- * `excerpt`, `image` and `lastModified` are not schema fields, so they are never
333
- * in the difference and never stripped. `content` is not exempt — it is
334
- * schema-governed, and usually the heavy field the split exists for.
338
+ * `excerpt`, `image` and `lastModified` are not sections, so they are never
339
+ * stripped. A markdown body goes where its content field is — inside `article_body`,
340
+ * say — so it is deferred with that section.
335
341
  *
336
342
  * ⛔ Silent on every path that cannot answer, because none of them is an error:
337
343
  *
@@ -342,8 +348,8 @@ export function toConfigQueries(declarations) {
342
348
  * sync lane already applies. `dist/meta/schema.json` carries the schemas
343
349
  * COMPONENTS reference, so a collection whose schema no component binds is
344
350
  * simply not there;
345
- * - the schema states no brief (`briefFields` → null, e.g. a root list) → there
346
- * is no lean shape to honour, so records stay whole.
351
+ * - the schema has no brief, or only one section (`recordLayout`) → there is no
352
+ * lean shape to honour, so records stay whole.
347
353
  *
348
354
  * The last two are why this reads the built artifact rather than resolving
349
355
  * schemas itself: it is the same input the sync lane uses, so both lanes agree
@@ -365,7 +371,7 @@ async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
365
371
  }
366
372
 
367
373
  /**
368
- * The `deferred:` a schema implies — every record field its **brief** does not name.
374
+ * The `deferred:` a schema implies — every top-level section but its **brief**, by name.
369
375
  *
370
376
  * ⛔ ONE IMPLEMENTATION, TWO CALLERS, and that is the point. `deriveDeferredFromSchemas`
371
377
  * above uses it to FILL an unstated `deferred:`; `uwx/records-project.js` uses it to
@@ -382,14 +388,63 @@ async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
382
388
  * derivation to recognize
383
389
  */
384
390
  export function deferredFromSchema(schema) {
385
- if (!schema) return null
386
- const brief = briefFields(schema)
387
- if (!brief) return null
388
- const all = Object.keys(flatRecordFields(schema) || {})
389
- const heavy = all.filter((f) => !brief.has(f))
391
+ const layout = schema ? recordLayout(schema) : null
392
+ if (!layout || layout.flat || !layout.brief) return null
393
+ const heavy = layout.sections.map(([name]) => name).filter((name) => name !== layout.brief)
390
394
  return heavy.length ? heavy : null
391
395
  }
392
396
 
397
+ /**
398
+ * The data schemas a site's records are shaped by, keyed by ref — each resolved from
399
+ * the LOCAL foundation's SOURCE by the resolver its build uses (`buildDataSchemaMap`),
400
+ * so the static lane delivers a record in the shape a component is written against
401
+ * (`query-processor.js`) whether or not the foundation has been built.
402
+ *
403
+ * ⛔ Not `dist/meta/schema.json`, which `foundationDataSchemas` below reads for the
404
+ * sync lane: a dev server does not build the foundation, so a site that had never been
405
+ * built would compile its records in one shape under `pnpm dev` and another in
406
+ * production — and a component reads them in exactly one.
407
+ *
408
+ * A `@/name` ref the foundation defines no schema for is absent without a word: a query
409
+ * is named after no schema when its records have none. Any other ref that does not
410
+ * resolve is absent too, and reported in `failures`. With no local foundation only
411
+ * `@std/*` refs resolve — from the build's own copy of the standard schemas — since
412
+ * nothing else is on disk.
413
+ *
414
+ * @param {string} siteRoot
415
+ * @param {Iterable<string>} refs - the `schema:` refs of the site's queries
416
+ * @param {{ siteYml?: object }} [opts] - an already-read site.yml
417
+ * @returns {Promise<{ schemas: object, failures: Array<{ ref: string, message: string }> }>}
418
+ */
419
+ export async function resolveRecordSchemas(siteRoot, refs, opts = {}) {
420
+ const wanted = [...new Set([...(refs || [])].filter((r) => typeof r === 'string' && r))]
421
+ const schemas = {}
422
+ const failures = []
423
+ if (wanted.length === 0) return { schemas, failures }
424
+ const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
425
+ const srcDir = localFoundationSrcDir(siteRoot, siteYml)
426
+ for (const ref of wanted) {
427
+ if (!srcDir && !ref.startsWith('@std/')) continue
428
+ try {
429
+ Object.assign(schemas, await buildDataSchemaMap([ref], { srcDir: srcDir ?? siteRoot }))
430
+ } catch (err) {
431
+ if (err?.code !== SCHEMA_NOT_FOUND) failures.push({ ref, message: err?.message || String(err) })
432
+ }
433
+ }
434
+ return { schemas, failures }
435
+ }
436
+
437
+ /** The source directory of the site's foundation when it is local, else null. */
438
+ function localFoundationSrcDir(siteRoot, siteYml) {
439
+ if (!siteYml?.foundation) return null
440
+ try {
441
+ const info = detectFoundationType(siteYml.foundation, siteRoot)
442
+ return info?.type === 'local' && info.path ? resolveFoundationSrcPath(info.path) : null
443
+ } catch {
444
+ return null // a declaration the resolver refuses is reported by the build that reads it
445
+ }
446
+ }
447
+
393
448
  /** The data schemas a site's foundation declares, or null when unresolvable. */
394
449
  export function foundationDataSchemas(siteRoot, siteYml) {
395
450
  return loadFoundationDataSchemas(siteRoot, siteYml)
@@ -64,7 +64,10 @@ import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.
64
64
  import { readEntityPool, groupPoolBySchema, poolDirsForSchema } from './entity-pool.js'
65
65
  import { readRecordsConfig, resolveFolder } from './records-config.js'
66
66
  import { isDraftRecord } from './record-draft.js'
67
+ import { resolveRecordSchemas } from './queries-config.js'
67
68
  import { parseFrontmatter } from '../utils/frontmatter.js'
69
+ import { toDeliveredRecord, contentBodyField, misplacedFields, mapReferences, recordLayout } from '@uniweb/schemas/conform'
70
+ import { collectNestedRefs } from '@uniweb/schemas/format'
68
71
 
69
72
  // Try to import content-reader for markdown parsing
70
73
  let markdownToProseMirror
@@ -451,33 +454,42 @@ async function processDataItemAssets(data, itemPath, siteRoot, recordsRoot, base
451
454
  *
452
455
  * @param {string} dir - Collection directory path
453
456
  * @param {string} filename - YAML filename (.yml or .yaml)
454
- * @param {boolean} [includeDrafts] - keep `draft: true` records (a preview)
457
+ * @param {Object} config - the parsed query (`includeDrafts`, `dataSchema`)
455
458
  * @returns {Promise<Object|Array|null>} Processed item(s), or null for a draft this run withholds
456
459
  */
457
- async function processDataItem(dir, filename, siteRoot, recordsRoot, basePath, includeDrafts = false) {
460
+ async function processDataItem(dir, filename, siteRoot, recordsRoot, basePath, config) {
458
461
  const filepath = join(dir, filename)
459
462
  const where = relative(siteRoot, filepath)
460
463
  const raw = await readFile(filepath, 'utf-8')
461
464
  const data = yaml.load(raw, YAML_OPTIONS) || {}
465
+ return processDataRecords(data, basename(filename, extname(filename)), filepath, where, siteRoot, recordsRoot, basePath, config)
466
+ }
462
467
 
468
+ /**
469
+ * The records a YAML or JSON file holds, delivered (`deliverRecord`): an array → one
470
+ * record per entry, each carrying its own `slug`; a mapping → one record, its `slug` the
471
+ * file's name unless it states one.
472
+ */
473
+ async function processDataRecords(data, fileSlug, filepath, where, siteRoot, recordsRoot, basePath, config) {
463
474
  // Array → multiple items (single-file collection). A draft entry is dropped
464
475
  // BEFORE its assets are copied, so nothing of it ships.
465
476
  if (Array.isArray(data)) {
466
- const kept = data.filter((item, i) => !withheld(item, `${where} [${i}]`, includeDrafts))
467
- for (const item of kept) {
477
+ const out = []
478
+ for (const [i, item] of data.entries()) {
479
+ if (withheld(item, `${where} [${i}]`, config.includeDrafts)) continue
468
480
  if (item && typeof item === 'object') {
469
481
  await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
470
482
  }
483
+ out.push(deliverRecord(item, config, `${where} [${i}]`))
471
484
  }
472
- return kept
485
+ return out
473
486
  }
474
487
 
475
488
  // Mapping → single item
476
- if (withheld(data, where, includeDrafts)) return null
477
- const slug = basename(filename, extname(filename))
478
- const item = { slug, ...data }
489
+ if (withheld(data, where, config.includeDrafts)) return null
490
+ const item = { slug: fileSlug, ...data }
479
491
  await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
480
- return item
492
+ return deliverRecord(item, config, where)
481
493
  }
482
494
 
483
495
  /**
@@ -489,33 +501,15 @@ async function processDataItem(dir, filename, siteRoot, recordsRoot, basePath, i
489
501
  *
490
502
  * @param {string} dir - Collection directory path
491
503
  * @param {string} filename - JSON filename
492
- * @param {boolean} [includeDrafts] - keep `draft: true` records (a preview)
504
+ * @param {Object} config - the parsed query (`includeDrafts`, `dataSchema`)
493
505
  * @returns {Promise<Object|Array|null>} Processed item(s), or null for a draft this run withholds
494
506
  */
495
- async function processJsonItem(dir, filename, siteRoot, recordsRoot, basePath, includeDrafts = false) {
507
+ async function processJsonItem(dir, filename, siteRoot, recordsRoot, basePath, config) {
496
508
  const filepath = join(dir, filename)
497
509
  const where = relative(siteRoot, filepath)
498
510
  const raw = await readFile(filepath, 'utf-8')
499
- const slug = basename(filename, '.json')
500
511
  const data = JSON.parse(raw)
501
-
502
- // Array → multiple items (single-file collection). A draft entry is dropped
503
- // BEFORE its assets are copied, so nothing of it ships.
504
- if (Array.isArray(data)) {
505
- const kept = data.filter((item, i) => !withheld(item, `${where} [${i}]`, includeDrafts))
506
- for (const item of kept) {
507
- if (item && typeof item === 'object') {
508
- await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
509
- }
510
- }
511
- return kept
512
- }
513
-
514
- // Object → single item
515
- if (withheld(data, where, includeDrafts)) return null
516
- const item = { slug, ...data }
517
- await processDataItemAssets(item, filepath, siteRoot, recordsRoot, basePath)
518
- return item
512
+ return processDataRecords(data, basename(filename, '.json'), filepath, where, siteRoot, recordsRoot, basePath, config)
519
513
  }
520
514
 
521
515
  /**
@@ -590,20 +584,127 @@ async function processContentItem(dir, filename, config, siteRoot, basePath, rec
590
584
  // This modifies content in place, updating paths to site-root-relative
591
585
  await processRecordAssets(content, filepath, siteRoot, recordsRoot, basePath)
592
586
 
593
- // Extract excerpt
594
- const excerpt = extractExcerpt(frontmatter, content, config.excerpt)
587
+ return deliverRecord({ slug, ...frontmatter }, config, relative(siteRoot, filepath), { doc: content, markdown: body })
588
+ }
595
589
 
596
- // Extract first image (frontmatter takes precedence)
597
- // Note: paths in the frontmatter and the content have already been rewritten above
598
- const image = frontmatter.image || extractFirstImage(content)
590
+ /**
591
+ * One record as its file holds it → the record a component receives.
592
+ *
593
+ * ⭐ THE SHAPE A HOST'S RECORDS SERVICE ANSWERS — measured 2026-09-24 on a local
594
+ * backend: the brief's fields at the top and every other section under its own name
595
+ * (`@uniweb/schemas/conform`'s `toDeliveredRecord`), with a markdown body in the
596
+ * schema's content body field — the ProseMirror document for a `format: prosemirror`
597
+ * field (`article_body.content`, for `@std/article`), the markdown source for a markup
598
+ * `text` one. A component is written against one shape, so a static site hands it that
599
+ * one (ruled 2026-09-24 [Diego]: the same as hosted). ⛔ Until then a record reached a
600
+ * component as its file held it, its body in `content` at the top — a shape no host
601
+ * delivers.
602
+ *
603
+ * `$name` is the record's handle, taken BEFORE the lift: a brief may declare a `slug`
604
+ * field of its own (`@std/article`'s does), and the lift puts that one over the file's.
605
+ *
606
+ * A record whose query has no data schema (`resolveRecordSchemas`) is delivered as its
607
+ * file holds it, a markdown body as `content`, which is what the static lane always did;
608
+ * so is a body its schema has no field for — a push refuses that one. A markdown record
609
+ * also gets `excerpt` and `image` where it states none, derived from its body: the
610
+ * static lane's own, and not what a host adds.
611
+ *
612
+ * Its references are delivered hydrated, as a host delivers them (`referenceHydrator`).
613
+ *
614
+ * ⛔ A record written in the retired flat form (`misplacedFields`) STOPS THE BUILD, as it
615
+ * stops a push: which section a key belongs in is not a guess to make.
616
+ *
617
+ * @param {*} record - the record as its file holds it, `slug` included
618
+ * @param {Object} config - the parsed query (`dataSchema`, `excerpt`)
619
+ * @param {string} where - the file, and the entry in a file of several, for a message
620
+ * @param {{ doc: Object, markdown: string }|null} [body] - a markdown record's body
621
+ * @returns {*} the delivered record (anything but a record, as it came)
622
+ */
623
+ function deliverRecord(record, config, where, body = null) {
624
+ if (!record || typeof record !== 'object' || Array.isArray(record)) return record
625
+ const schema = config.dataSchema || null
626
+ let out
627
+ if (!schema) {
628
+ out = body
629
+ ? {
630
+ ...record,
631
+ excerpt: extractExcerpt(record, body.doc, config.excerpt),
632
+ // paths in the record and the body have already been rewritten
633
+ image: record.image || extractFirstImage(body.doc),
634
+ content: body.doc,
635
+ }
636
+ : { ...record }
637
+ } else {
638
+ const misplaced = misplacedFields(schema, record)
639
+ if (misplaced.length) throw new Error(flatFormRefusal(where, config.schema, schema, misplaced))
640
+ out = { ...toDeliveredRecord(schema, record) }
641
+ if (body) {
642
+ placeBody(out, schema, body)
643
+ out.excerpt = extractExcerpt(out, body.doc, config.excerpt)
644
+ out.image = out.image || extractFirstImage(body.doc)
645
+ }
646
+ if (config.references) out = config.references.deliver(schema, out, where)
647
+ }
648
+ const handle = record.slug
649
+ if (handle !== undefined && handle !== null && handle !== '') out.$name = String(handle)
650
+ return out
651
+ }
599
652
 
600
- return {
601
- slug,
602
- ...frontmatter,
603
- excerpt,
604
- image,
605
- content
653
+ /**
654
+ * Put a markdown body where the delivered record carries its schema's content body
655
+ * field (`contentBodyField`) — unless the record already states that field, or the body
656
+ * is empty. With no such field the body stays `content`, at the top.
657
+ */
658
+ function placeBody(out, schema, body) {
659
+ const blank = !body.markdown || !body.markdown.trim()
660
+ const target = contentBodyField(schema)
661
+ if (!target) {
662
+ if (!blank) out.content = body.doc
663
+ return
606
664
  }
665
+ if (blank) return
666
+ const value = target.field?.type === 'text' ? body.markdown : body.doc
667
+ if (!target.section) {
668
+ if (out[target.key] == null) out[target.key] = value
669
+ return
670
+ }
671
+ const held = out[target.section]
672
+ // A section that is not a record is the file's to fix — `uniweb validate` names it.
673
+ if (held != null && (typeof held !== 'object' || Array.isArray(held))) return
674
+ if (held?.[target.key] != null) return
675
+ out[target.section] = { ...(held || {}), [target.key]: value }
676
+ }
677
+
678
+ /**
679
+ * The message a record written in the retired flat form stops the build with — each
680
+ * misplaced key (`misplacedFields`) moved under the section that declares it.
681
+ *
682
+ * @param {string} where - the file, for the message
683
+ * @param {string|null} ref - the schema's ref, as the query names it
684
+ * @param {Object} schema - the normalized schema
685
+ * @param {Array<{ key: string, sections: string[] }>} misplaced
686
+ * @returns {string}
687
+ */
688
+ export function flatFormRefusal(where, ref, schema, misplaced) {
689
+ const under = new Map()
690
+ const either = []
691
+ for (const { key, sections } of misplaced) {
692
+ if (sections.length === 1) {
693
+ if (!under.has(sections[0])) under.set(sections[0], [])
694
+ under.get(sections[0]).push(key)
695
+ } else {
696
+ either.push(`"${key}" under ${sections.map((n) => `"${n}:"`).join(' or ')}`)
697
+ }
698
+ }
699
+ const moves = [
700
+ ...[...under].map(([section, keys]) => `${keys.map((k) => `"${k}"`).join(', ')} under "${section}:"`),
701
+ ...either,
702
+ ]
703
+ const names = Object.keys(schema.sections || {}).map((n) => `"${n}"`).join(', ')
704
+ return (
705
+ `[uniweb] ${where}: ${ref || schema.name} records are written by section, each section ` +
706
+ `under its own name (${names}) — move ${moves.join('; ')}.`
707
+ )
607
708
  }
608
709
 
609
710
  /**
@@ -663,6 +764,125 @@ function warnDuplicateSlugs(items, queryName) {
663
764
  }
664
765
  }
665
766
 
767
+ /**
768
+ * Read a set of pooled record files, each by its format (markdown → content items,
769
+ * YAML/JSON → data items, BibTeX → CSL-JSON), delivering each as `config` says. A
770
+ * record's co-located assets are copied by their path under the records directory
771
+ * (`recordAssetPath`).
772
+ *
773
+ * @returns {Promise<Array>} one result per file — a record, a list of them, or null
774
+ */
775
+ function readPooledRecords(pooled, config, siteDir, recordsRoot, basePath) {
776
+ return Promise.all(
777
+ pooled.map((e) => {
778
+ const dir = resolve(recordsRoot, ...e.dirs)
779
+ const file = `${e.slug}${e.ext}`
780
+ if (e.ext === '.bib') {
781
+ return processBibtexItem(dir, file, siteDir, config.includeDrafts)
782
+ }
783
+ if (e.ext === '.json') {
784
+ return processJsonItem(dir, file, siteDir, recordsRoot, basePath, config)
785
+ }
786
+ if (e.ext === '.yml' || e.ext === '.yaml') {
787
+ return processDataItem(dir, file, siteDir, recordsRoot, basePath, config)
788
+ }
789
+ return processContentItem(dir, file, config, siteDir, basePath, recordsRoot)
790
+ })
791
+ )
792
+ }
793
+
794
+ /**
795
+ * References, delivered as a host's records service delivers them — measured 2026-09-24
796
+ * on a local backend: `{ entity, brief }`, the record a reference names reduced to its
797
+ * brief's fields, `entity` being its id (here, the one its file carries, if any). So a
798
+ * component reads `talk.speaker.brief.name` on a static site and a hosted one alike.
799
+ * ⚠️ A host also sends `model`, the Model's id on that backend; nothing a static build
800
+ * holds corresponds to it, and whether it stays is backend's to say.
801
+ *
802
+ * The records a reference names are read here, once per data schema, whether or not a
803
+ * query selects them — and without their own references delivered, as a host delivers
804
+ * one level.
805
+ *
806
+ * A reference names its record by handle — its file's name, or `slug:` — or by the id
807
+ * its file carries. One that names no record of its schema is delivered as written, with
808
+ * a warning; `uniweb validate` reports it too.
809
+ */
810
+ function referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, basePath, includeDrafts }) {
811
+ const targets = new Map() // schema ref → Map(name or own id → the delivered reference)
812
+ const warned = new Set()
813
+
814
+ const load = async (ref) => {
815
+ const schema = dataSchemas[ref] || null
816
+ const pooled = poolBySchema.get(ref) || []
817
+ const byName = new Map()
818
+ if (!schema || pooled.length === 0) return byName
819
+ const config = { schema: ref, dataSchema: schema, includeDrafts, excerpt: { maxLength: 160 }, references: null }
820
+ const keys = briefKeysOf(schema)
821
+ for (const result of await readPooledRecords(pooled, config, siteDir, recordsRoot, basePath)) {
822
+ for (const record of [result].flat()) {
823
+ if (!record || typeof record !== 'object') continue
824
+ const delivered = {
825
+ ...(typeof record.$uuid === 'string' ? { entity: record.$uuid } : {}),
826
+ brief: pickBrief(record, keys),
827
+ }
828
+ if (record.$name !== undefined) byName.set(String(record.$name), delivered)
829
+ if (typeof record.$uuid === 'string') byName.set(record.$uuid, delivered)
830
+ }
831
+ }
832
+ return byName
833
+ }
834
+
835
+ return {
836
+ async prepare(refs) {
837
+ for (const ref of refs || []) {
838
+ if (!targets.has(ref)) targets.set(ref, await load(ref))
839
+ }
840
+ },
841
+ deliver(schema, record, where) {
842
+ return mapReferences(
843
+ schema,
844
+ record,
845
+ (value, { path, ref }) => {
846
+ const hit = typeof value === 'string' ? targets.get(ref)?.get(value) : undefined
847
+ if (hit) return hit
848
+ const key = `${where} ${path}`
849
+ if (typeof value === 'string' && !warned.has(key)) {
850
+ warned.add(key)
851
+ console.warn(`[query-processor] ${where}: "${path}" names "${value}", and no record of ${ref} is called that.`)
852
+ }
853
+ return value
854
+ },
855
+ { delivered: true }
856
+ )
857
+ },
858
+ }
859
+ }
860
+
861
+ // The keys of a record's brief — the fields of the section a reference hydrates to: the
862
+ // one section of a flat schema, else the brief. Null when there is none (a list at the
863
+ // root), and the whole record stands in for it, as a host answers such a Model.
864
+ function briefKeysOf(schema) {
865
+ const layout = recordLayout(schema)
866
+ if (!layout) return null
867
+ if (schema.fields) return Object.keys(schema.fields)
868
+ const def = layout.flat ? layout.sections[0][1] : layout.sections.find(([name]) => name === layout.brief)?.[1]
869
+ return def ? [...Object.keys(def.fields || {}), ...Object.keys(def.sections || {})] : null
870
+ }
871
+
872
+ // A delivered record's brief: its brief's fields that it has, or — with no brief — every
873
+ // field but the record's own keys.
874
+ function pickBrief(record, keys) {
875
+ const out = {}
876
+ if (keys) {
877
+ for (const key of keys) if (record[key] !== undefined) out[key] = record[key]
878
+ return out
879
+ }
880
+ for (const [key, value] of Object.entries(record)) {
881
+ if (!key.startsWith('$') && !['slug', 'path', 'excerpt', 'image', 'draft'].includes(key)) out[key] = value
882
+ }
883
+ return out
884
+ }
885
+
666
886
  /**
667
887
  * Collect and process all of a query's records
668
888
  *
@@ -679,28 +899,15 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
679
899
  const pooled = config.poolEntities || []
680
900
  if (pooled.length === 0) return []
681
901
 
682
- // A record's co-located assets are copied by their path under the records
683
- // directory (`recordAssetPath`).
684
- const dirOf = (e) => resolve(recordsRoot, ...e.dirs)
902
+ // The records this query's references name, read before its own — delivering one
903
+ // is synchronous (`deliverRecord`).
904
+ if (config.dataSchema && config.references) {
905
+ await config.references.prepare(collectNestedRefs(config.dataSchema))
906
+ }
685
907
 
686
908
  // Process every record file (markdown → content items, YAML/JSON → data items,
687
909
  // BibTeX → CSL-JSON bibliography items).
688
- let items = await Promise.all(
689
- pooled.map((e) => {
690
- const dir = dirOf(e)
691
- const file = `${e.slug}${e.ext}`
692
- if (e.ext === '.bib') {
693
- return processBibtexItem(dir, file, siteDir, config.includeDrafts)
694
- }
695
- if (e.ext === '.json') {
696
- return processJsonItem(dir, file, siteDir, recordsRoot, basePath, config.includeDrafts)
697
- }
698
- if (e.ext === '.yml' || e.ext === '.yaml') {
699
- return processDataItem(dir, file, siteDir, recordsRoot, basePath, config.includeDrafts)
700
- }
701
- return processContentItem(dir, file, config, siteDir, basePath, recordsRoot)
702
- })
703
- )
910
+ let items = await readPooledRecords(pooled, config, siteDir, recordsRoot, basePath)
704
911
 
705
912
  // ⭐ `path` IS THE FOLDER `folder.yml` PLACED THE RECORD IN — `''` at the top —
706
913
  // and it is the whole reason folders exist: `scope: archive` is how a query asks
@@ -729,13 +936,15 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
729
936
 
730
937
  // ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
731
938
  // field a `[slug]` or `[...path]` page matches, and the one the records service
732
- // serves. It is the record's FINAL slug: set here, after every format has been
733
- // read and flattened, so a frontmatter `slug:` (which wins over the filename),
734
- // a BibTeX cite key and an array-form file's own `slug` all count — exactly what
735
- // our sync sends as the entry's name (`uwx/entity-source.js`). `slug` stays:
736
- // foundations and templates read it.
939
+ // serves. It is the record's FINAL slug — a frontmatter `slug:` (which wins over the
940
+ // filename), a BibTeX cite key and an array-form file's own `slug` all count — exactly
941
+ // what our sync sends as the entry's name (`uwx/entity-source.js`). `deliverRecord`
942
+ // sets it for a markdown, YAML or JSON record, before the brief is lifted (a brief's
943
+ // own `slug` FIELD must not become the handle); a BibTeX entry gets it here. `slug`
944
+ // stays: foundations and templates read it.
737
945
  items = items.map((item) => (
738
- item && typeof item === 'object' && item.slug !== undefined && item.slug !== null && item.slug !== ''
946
+ item && typeof item === 'object' && item.$name === undefined &&
947
+ item.slug !== undefined && item.slug !== null && item.slug !== ''
739
948
  ? { ...item, $name: String(item.slug) }
740
949
  : item
741
950
  ))
@@ -799,6 +1008,8 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
799
1008
  * @param {boolean} [options.includeDrafts=false] - keep `draft: true` records: a preview
800
1009
  * (`pnpm dev`) or a check (`uniweb validate`). Off for anything a site DELIVERS — a
801
1010
  * draft is a record that is not delivered while the site is published.
1011
+ * @param {Object} [options.dataSchemas] - normalized data schemas by ref, to deliver the
1012
+ * records in; resolved from the site's foundation when absent (`resolveRecordSchemas`)
802
1013
  * @returns {Promise<Object>} Map of query name to items array
803
1014
  *
804
1015
  * @example
@@ -807,7 +1018,8 @@ async function collectItems(siteDir, config, recordsRoot, basePath, locale = nul
807
1018
  * })
808
1019
  * // { articles: [...] }
809
1020
  */
810
- export async function processQueries(siteDir, queriesConfig, recordsDir, basePath = '/', { locale = null, includeDrafts = false } = {}) {
1021
+ export async function processQueries(siteDir, queriesConfig, recordsDir, basePath = '/', options = {}) {
1022
+ const { locale = null, includeDrafts = false } = options
811
1023
  if (!queriesConfig || typeof queriesConfig !== 'object') {
812
1024
  return {}
813
1025
  }
@@ -842,6 +1054,12 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
842
1054
  const poolBySchema = groupPoolBySchema(pool.entities)
843
1055
  const recordsRoot = resolve(siteDir, pool.dir)
844
1056
 
1057
+ // ⭐ THE DATA SCHEMA EACH QUERY'S RECORDS ARE DELIVERED IN (`deliverRecord`) — resolved
1058
+ // from the foundation's source once for every query. A query with none compiles its
1059
+ // records as their files hold them.
1060
+ const dataSchemas = options.dataSchemas ?? (await querySchemas(siteDir, queriesConfig))
1061
+ const references = referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, basePath, includeDrafts })
1062
+
845
1063
  const results = {}
846
1064
 
847
1065
  for (const [name, config] of Object.entries(queriesConfig)) {
@@ -853,6 +1071,8 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
853
1071
  parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
854
1072
  parsed.placements = folder.placements
855
1073
  parsed.includeDrafts = includeDrafts
1074
+ parsed.dataSchema = (parsed.schema && dataSchemas[parsed.schema]) || null
1075
+ parsed.references = references
856
1076
  if (parsed.poolEntities.length === 0) {
857
1077
  const dirs = parsed.schema ? poolDirsForSchema(parsed.schema) : null
858
1078
  console.warn(
@@ -870,6 +1090,29 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
870
1090
  return results
871
1091
  }
872
1092
 
1093
+ /**
1094
+ * The data schemas of the site's record queries, by ref (`resolveRecordSchemas`). A ref
1095
+ * that is not a schema-less query's name and still does not resolve is warned about:
1096
+ * its records are compiled as their files hold them, which is not the shape a
1097
+ * component receives from a host.
1098
+ */
1099
+ async function querySchemas(siteDir, queriesConfig) {
1100
+ const refs = []
1101
+ for (const config of Object.values(queriesConfig)) {
1102
+ if (config && typeof config === 'object' && config.url !== undefined) continue
1103
+ const ref = typeof config === 'string' ? config : config?.schema
1104
+ if (typeof ref === 'string') refs.push(ref)
1105
+ }
1106
+ const { schemas, failures } = await resolveRecordSchemas(siteDir, refs)
1107
+ for (const { ref, message } of failures) {
1108
+ console.warn(
1109
+ `[query-processor] Data schema ${ref} did not resolve, so its records are compiled as ` +
1110
+ `their files hold them rather than as a component receives them: ${message}`
1111
+ )
1112
+ }
1113
+ return schemas
1114
+ }
1115
+
873
1116
  /**
874
1117
  * Reconcile a deferred collection's per-record directory with the records it
875
1118
  * should hold this run — delete the `<slug>.json` files that are no longer