@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.
- package/package.json +9 -9
- package/src/i18n/records.js +32 -17
- package/src/prerender.js +16 -11
- package/src/resolve-data-schema.js +4 -2
- package/src/runtime-schema.js +15 -18
- package/src/site/data-fetcher.js +1 -1
- package/src/site/entity-pool.js +2 -2
- package/src/site/file-transport.js +3 -2
- package/src/site/queries-config.js +46 -83
- package/src/site/query-processor.js +264 -149
- package/src/site/record-file.js +35 -0
- package/src/site/records-config.js +22 -1
- package/src/uwx/backfill.js +9 -7
- package/src/uwx/data-schema.js +8 -24
- package/src/uwx/entity-source.js +9 -20
- package/src/uwx/queries-config.js +0 -1
- package/src/uwx/record-layout.js +1 -1
- package/src/uwx/records-project.js +17 -94
- package/src/uwx/records.js +1 -26
- package/src/uwx/site-project.js +2 -5
- package/src/uwx/site.js +6 -6
- package/src/validate-data.js +3 -2
- package/src/vite-foundation-plugin.js +39 -193
|
@@ -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 {
|
|
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
|
|
462
|
-
*
|
|
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|
|
|
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
|
|
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
|
-
//
|
|
486
|
-
|
|
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
|
|
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|
|
|
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
|
|
531
|
-
*
|
|
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 `
|
|
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 => ({
|
|
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 (`
|
|
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
|
|
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,
|
|
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(
|
|
637
|
-
if (!
|
|
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
|
|
728
|
-
*
|
|
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
|
|
766
|
-
const
|
|
767
|
-
const where = item
|
|
768
|
-
if (seen.has(
|
|
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
|
|
771
|
-
`
|
|
772
|
-
`per-record file resolve to only one of them — give them distinct
|
|
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(
|
|
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:
|
|
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', '
|
|
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
|
-
// ⭐ `
|
|
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
|
|
939
|
-
if (Array.isArray(result)) return result.map((item) => item && { ...item,
|
|
940
|
-
return result && { ...result,
|
|
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:
|
|
944
|
-
//
|
|
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
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
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
|
-
|
|
987
|
-
|
|
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 (
|
|
994
|
-
|
|
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 (`
|
|
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
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
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 `
|
|
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.
|
|
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
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
const
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
|
|
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]
|
|
1240
|
-
`
|
|
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
|
}
|