@uniweb/build 0.60.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.
- package/package.json +7 -7
- package/src/i18n/freeform.js +3 -1
- package/src/i18n/records.js +161 -80
- package/src/index.js +0 -1
- package/src/resolve-data-schema.js +11 -1
- package/src/runtime-schema.js +14 -16
- package/src/site/queries-config.js +68 -13
- package/src/site/query-processor.js +310 -67
- package/src/uwx/backfill.js +115 -52
- package/src/uwx/data-schema.js +16 -11
- package/src/uwx/project-writer.js +4 -2
- package/src/uwx/record-layout.js +61 -0
- package/src/uwx/records-project.js +23 -18
- package/src/uwx/records.js +370 -201
- package/src/uwx/registry-package.js +3 -3
- package/src/uwx/sync-package.js +66 -4
- package/src/uwx/uuid.js +7 -0
- package/src/validate-data.js +97 -180
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
|
|
31
31
|
import { join } from 'node:path'
|
|
32
32
|
import { existsSync, readFileSync } from 'node:fs'
|
|
33
|
-
import {
|
|
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
|
|
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
|
|
333
|
-
*
|
|
334
|
-
*
|
|
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
|
|
346
|
-
*
|
|
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
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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 {
|
|
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,
|
|
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
|
|
467
|
-
for (const item of
|
|
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
|
|
485
|
+
return out
|
|
473
486
|
}
|
|
474
487
|
|
|
475
488
|
// Mapping → single item
|
|
476
|
-
if (withheld(data, where, includeDrafts)) return null
|
|
477
|
-
const
|
|
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 {
|
|
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,
|
|
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
|
-
|
|
594
|
-
|
|
587
|
+
return deliverRecord({ slug, ...frontmatter }, config, relative(siteRoot, filepath), { doc: content, markdown: body })
|
|
588
|
+
}
|
|
595
589
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
-
//
|
|
683
|
-
//
|
|
684
|
-
|
|
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
|
|
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
|
|
733
|
-
//
|
|
734
|
-
//
|
|
735
|
-
//
|
|
736
|
-
//
|
|
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
|
|
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 = '/',
|
|
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
|