@uniweb/build 0.56.0 → 0.57.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/content/index.js +3 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +9 -5
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +4 -3
- package/src/site/entity-pool.js +136 -42
- package/src/site/plugin.js +12 -9
- package/src/site/queries-config.js +15 -5
- package/src/site/query-processor.js +126 -112
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +101 -93
- package/src/uwx/asset-map.js +2 -1
- package/src/uwx/backfill.js +2 -2
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +9 -9
- package/src/uwx/records-project.js +95 -64
- package/src/uwx/records.js +310 -214
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +3 -3
- package/src/uwx/site.js +45 -39
- package/src/uwx/sync-package.js +19 -15
- package/src/validate-data.js +3 -1
package/src/uwx/records.js
CHANGED
|
@@ -63,6 +63,7 @@ import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
|
63
63
|
import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
|
|
64
64
|
import { localizeScalar, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
65
65
|
import { loadFreeformRecord } from '../i18n/freeform.js'
|
|
66
|
+
import { isDraftRecord } from '../site/record-draft.js'
|
|
66
67
|
|
|
67
68
|
const DATE_KINDS = new Set(['date', 'datetime'])
|
|
68
69
|
// Identity/transport keys on a source record — never Model fields, never warned.
|
|
@@ -79,6 +80,8 @@ const SKIP_KEYS = new Set([
|
|
|
79
80
|
'$unit',
|
|
80
81
|
'$meta',
|
|
81
82
|
'$body',
|
|
83
|
+
// The draft flag (`site/record-draft.js`) — framework's, never a Model field.
|
|
84
|
+
'draft',
|
|
82
85
|
])
|
|
83
86
|
|
|
84
87
|
// Recursively drop IDENTITY `$`-sigil keys (`$uuid`/`$id`/`$model`/… — never
|
|
@@ -107,11 +110,12 @@ function stripSigils(value) {
|
|
|
107
110
|
//
|
|
108
111
|
// ⭐ Neither encoding is content. What the folder SAYS is "this branch contains
|
|
109
112
|
// this record, here, in this order" — and that is already hashed: a leaf carries
|
|
110
|
-
// `name` (the record's handle) inside
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// and `entry` adds identity, and both are exactly what
|
|
113
|
+
// `name` (the record's handle) at its position inside the branch that holds it.
|
|
114
|
+
// Two siblings may share a name — records of different schemas can — and position
|
|
115
|
+
// still tells them apart; a leaf that comes to reference another record arrives
|
|
116
|
+
// with that record changed, which re-sends the folder anyway. `$ref` adds a
|
|
117
|
+
// payload-local handle and `entry` adds identity, and both are exactly what
|
|
118
|
+
// `$uuid` is stripped for.
|
|
115
119
|
//
|
|
116
120
|
// ⚖️ The previous rule kept `$ref` "so a reference change is visible". It still
|
|
117
121
|
// is: point a leaf at a different record and its `name` moves with it.
|
|
@@ -181,13 +185,15 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
/**
|
|
184
|
-
* Map
|
|
188
|
+
* Map records of one schema to entity-content `$`-documents of
|
|
185
189
|
* `declaration`'s Model. PURE — records + declaration in, entity descriptors out;
|
|
186
190
|
* no I/O, no minting. The backend mints `$uuid` on first sync; a record that
|
|
187
191
|
* already carries `$uuid` (back-filled from a prior sync) round-trips it.
|
|
188
192
|
*
|
|
189
193
|
* @param {object} params
|
|
190
|
-
* @param {string} params.
|
|
194
|
+
* @param {string} params.label - what the records are, for messages and the path
|
|
195
|
+
* inside the package — the schema folder they came from (`article`,
|
|
196
|
+
* `std/person`)
|
|
191
197
|
* @param {object[]} params.records - [{ slug, ...fields }]
|
|
192
198
|
* @param {object} params.declaration - the `@uniweb/data-schema` declaration
|
|
193
199
|
* (from toDataSchemaDeclaration): `{ name, brief, sections }`
|
|
@@ -198,7 +204,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
198
204
|
* `{ id, uuid, model, file, document }` — `document` is the section-keyed body.
|
|
199
205
|
*/
|
|
200
206
|
export function recordsToEntities({
|
|
201
|
-
|
|
207
|
+
label,
|
|
202
208
|
records,
|
|
203
209
|
declaration,
|
|
204
210
|
sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale,
|
|
@@ -267,7 +273,7 @@ export function recordsToEntities({
|
|
|
267
273
|
const warnings = []
|
|
268
274
|
if (contentMatches.length > 1) {
|
|
269
275
|
warnings.push(
|
|
270
|
-
`${
|
|
276
|
+
`${label}: ${declaration.name} has more than one content ` +
|
|
271
277
|
`(markdown / html / prosemirror) field — the markdown body maps to ` +
|
|
272
278
|
`"${bodyTarget.secName}.${bodyTarget.key}"`
|
|
273
279
|
)
|
|
@@ -275,7 +281,7 @@ export function recordsToEntities({
|
|
|
275
281
|
for (const record of records || []) {
|
|
276
282
|
const slug = record.slug
|
|
277
283
|
if (!slug) {
|
|
278
|
-
warnings.push(`${
|
|
284
|
+
warnings.push(`${label}: a record without a slug was skipped`)
|
|
279
285
|
continue
|
|
280
286
|
}
|
|
281
287
|
// ⛔ `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
|
|
@@ -283,18 +289,16 @@ export function recordsToEntities({
|
|
|
283
289
|
// frontmatter `$id` wins.
|
|
284
290
|
//
|
|
285
291
|
// ⚠️ The authoritative value is the record's POOL POSITION — `<dirs>/<slug>` —
|
|
286
|
-
// and it is set upstream,
|
|
287
|
-
// where the reasoning lives. `<
|
|
288
|
-
// record that did not arrive
|
|
289
|
-
// shape identity is meant to take: two queries over one Model would mint two
|
|
290
|
-
// identities for one file.
|
|
292
|
+
// and it is set upstream, where the records directory is walked; see the ⭐
|
|
293
|
+
// comment there, which is where the reasoning lives. `<label>/<slug>` below is
|
|
294
|
+
// only the fallback for a record that did not arrive that way.
|
|
291
295
|
//
|
|
292
296
|
// The qualification is a CONSTRAINT, not a style: the sync response is keyed per
|
|
293
|
-
// (`$model`, `$id`), so a bare slug would collide whenever two
|
|
294
|
-
//
|
|
297
|
+
// (`$model`, `$id`), so a bare slug would collide whenever two schema folders
|
|
298
|
+
// resolving to one Model reuse one (see the duplicate check). ⇒ Do not describe this
|
|
295
299
|
// value as "the slug" — the folder leaf's `name` is the bare segment, and
|
|
296
300
|
// conflating the two has already misdirected a naming decision.
|
|
297
|
-
const id = record.$id || `${
|
|
301
|
+
const id = record.$id || `${label}/${slug}`
|
|
298
302
|
const uuid = record.$uuid || null
|
|
299
303
|
const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
|
|
300
304
|
|
|
@@ -323,13 +327,13 @@ export function recordsToEntities({
|
|
|
323
327
|
for (const key of Object.keys(record)) {
|
|
324
328
|
if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
|
|
325
329
|
warnings.push(
|
|
326
|
-
`${
|
|
330
|
+
`${label}/${slug}: field "${key}" is not on ` +
|
|
327
331
|
`${declaration.name} — not synced`
|
|
328
332
|
)
|
|
329
333
|
}
|
|
330
334
|
if (hasBody && !bodyTarget) {
|
|
331
335
|
warnings.push(
|
|
332
|
-
`${
|
|
336
|
+
`${label}/${slug}: markdown body present but ` +
|
|
333
337
|
`${declaration.name} has no content body field — body not synced`
|
|
334
338
|
)
|
|
335
339
|
}
|
|
@@ -351,7 +355,10 @@ export function recordsToEntities({
|
|
|
351
355
|
uuid,
|
|
352
356
|
slug,
|
|
353
357
|
model: declaration.name, // reference the Model BY NAME — importer resolves it
|
|
354
|
-
file
|
|
358
|
+
// The file's place inside the package — opaque to the reader, which follows
|
|
359
|
+
// `entries[].file` (`entity-document.js`). Label + slug, not `$id`: a slug is
|
|
360
|
+
// unique within its schema folder, and an authored `$id` need not be.
|
|
361
|
+
file: `entities/${label}/${slug}.json`,
|
|
355
362
|
document,
|
|
356
363
|
})
|
|
357
364
|
}
|
|
@@ -359,14 +366,13 @@ export function recordsToEntities({
|
|
|
359
366
|
}
|
|
360
367
|
|
|
361
368
|
// Post-pass: override a collection record's localized CONTENT body with a per-locale
|
|
362
|
-
// FREE-FORM body when `locales/freeform/{locale}/
|
|
369
|
+
// FREE-FORM body when `locales/freeform/{locale}/records/<schema>/<slug>.md` exists
|
|
363
370
|
// — the override wins over the structural map, exactly like site-content sections
|
|
364
371
|
// (site.js localizeContentTree). Only a `format: prosemirror` localized field can
|
|
365
372
|
// take it (it is a PM doc on the wire; a markup `text` body stays a raw string).
|
|
366
373
|
// Mutates the entity documents in place. Async — the free-form read hits the disk.
|
|
367
374
|
async function applyFreeformRecordOverrides({
|
|
368
375
|
entities,
|
|
369
|
-
queryName,
|
|
370
376
|
declaration,
|
|
371
377
|
sourceLocale,
|
|
372
378
|
targetLocales,
|
|
@@ -399,15 +405,44 @@ async function applyFreeformRecordOverrides({
|
|
|
399
405
|
|
|
400
406
|
// --- orchestration (file I/O) ------------------------------------------------
|
|
401
407
|
|
|
402
|
-
// The
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
+
// The queries over the SITE'S records — every declaration with a schema, which is
|
|
409
|
+
// every one without `url:` (an external query's records are its address's).
|
|
410
|
+
//
|
|
411
|
+
// ⛔ They do not decide what is pushed: the records directory does. A query matters
|
|
412
|
+
// here for two things only — whether its schema was ASKED FOR (an explicit schema
|
|
413
|
+
// that resolves to nothing is an error, a defaulted one is not), and which queries
|
|
414
|
+
// ship as static files because their schema resolved to nothing.
|
|
415
|
+
function siteQueries(declarations) {
|
|
408
416
|
const out = []
|
|
409
417
|
for (const decl of Object.values(declarations)) {
|
|
410
|
-
if (
|
|
418
|
+
if (decl.schema || decl.model) out.push({ name: decl.name, decl })
|
|
419
|
+
}
|
|
420
|
+
return out
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Replace each folder leaf — which names a FILE — with one leaf per record that file
|
|
424
|
+
// produced, and drop a leaf whose file produced none.
|
|
425
|
+
//
|
|
426
|
+
// ⭐ A FILE IS NOT ALWAYS ONE RECORD, and a record's `$id` is not always its file's
|
|
427
|
+
// stem. A BibTeX or array-form file holds several; a `.md` whose frontmatter sets
|
|
428
|
+
// `slug:` is one record named for that slug. The folder placed files and the payload
|
|
429
|
+
// carries records, so until 2026-09-21 every such leaf pointed at an id nothing
|
|
430
|
+
// produced and was dropped with a warning — harmless while `records.yml` rarely listed
|
|
431
|
+
// those files, and on every push once every file in the directory is placed.
|
|
432
|
+
//
|
|
433
|
+
// ⚠️ A file that produced nothing is dropped WITHOUT a warning here: its schema did
|
|
434
|
+
// not resolve (reported as schemaless), or its records were skipped (reported while
|
|
435
|
+
// reading them). Saying it a second time, as a folder problem, names the wrong cause.
|
|
436
|
+
function placeProducedRecords(nodes, producedBy) {
|
|
437
|
+
const out = []
|
|
438
|
+
for (const node of nodes || []) {
|
|
439
|
+
if (node?.kind === 'branch') {
|
|
440
|
+
out.push({ ...node, $children: placeProducedRecords(node.$children, producedBy) })
|
|
441
|
+
continue
|
|
442
|
+
}
|
|
443
|
+
for (const r of producedBy.get(node?.$entityId) || []) {
|
|
444
|
+
out.push({ kind: 'ref', name: r.slug, $entityId: r.id })
|
|
445
|
+
}
|
|
411
446
|
}
|
|
412
447
|
return out
|
|
413
448
|
}
|
|
@@ -492,38 +527,13 @@ function resolveDeclaration(schema, modelName) {
|
|
|
492
527
|
return null
|
|
493
528
|
}
|
|
494
529
|
|
|
495
|
-
// Load a query's ORIGINAL source records for export — the author's files,
|
|
496
|
-
// untouched (raw frontmatter + raw markdown body, raw YAML/JSON, raw BibTeX). This
|
|
497
|
-
// is deliberately NOT `processQueries` (the delivery pipeline that builds
|
|
498
|
-
// public/data, converts bodies to ProseMirror, and copies assets). Sync carries
|
|
499
|
-
// the source.
|
|
500
|
-
//
|
|
501
|
-
// ⭐ THE QUERY NAMES A SCHEMA AND THE POOL FOLLOWS. It does not name a directory,
|
|
502
|
-
// and there is no disk path for it to name: `entities/{schema}/` declares the
|
|
503
|
-
// model, so the entities of a schema ARE its records. That is the de-conflation —
|
|
504
|
-
// `collections/<name>/` used to answer "which files", "which schema" and "grouped
|
|
505
|
-
// how" with one directory, and only the first two were ever the same question.
|
|
506
|
-
//
|
|
507
|
-
// Remote (`url:`) queries have no local files; the caller warns and skips.
|
|
508
|
-
function loadSourceRecordsFromPool(poolBySchema, decl, placements) {
|
|
509
|
-
if (!decl.schema) return null
|
|
510
|
-
const entities = poolBySchema.get(decl.schema)
|
|
511
|
-
if (!entities) return null
|
|
512
|
-
// ⛔ ONLY WHAT `records.yml` REFERENCES. An entity of the right schema that no
|
|
513
|
-
// entry places is not a record, so syncing it would create something nobody can
|
|
514
|
-
// reach — and would make the payload disagree with the folder describing it.
|
|
515
|
-
const placed = entities.filter((e) => placements.has(e.id))
|
|
516
|
-
return placed.length ? placed : null
|
|
517
|
-
}
|
|
518
|
-
|
|
519
530
|
/**
|
|
520
|
-
* Build the
|
|
521
|
-
*
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* duplicate ($model, $id) within the submission.
|
|
531
|
+
* Build the record entity descriptors + back-fill index for a site's records —
|
|
532
|
+
* PURE assembly (no hashing, no emit), so it composes with other entity sources
|
|
533
|
+
* (e.g. site-content) into one sync package. First sync sends no `$uuid` (the
|
|
534
|
+
* backend mints); re-sync round-trips the back-filled `$uuid`. Throws on an
|
|
535
|
+
* unresolvable EXPLICIT Model, an invalid `records.yml`, or a duplicate
|
|
536
|
+
* ($model, $id) within the submission.
|
|
527
537
|
*
|
|
528
538
|
* @param {string} siteRoot - directory containing site.yml
|
|
529
539
|
* @param {object} [opts]
|
|
@@ -533,51 +543,77 @@ function loadSourceRecordsFromPool(poolBySchema, decl, placements) {
|
|
|
533
543
|
* `@uniweb/data-schema` declaration (or null). The verb wires this to the
|
|
534
544
|
* backend's Model-read route. Without it, the local foundation is required.
|
|
535
545
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale
|
|
536
|
-
* @returns {Promise<{ entities: object[], index: object[], warnings: string[],
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
546
|
+
* @returns {Promise<{ entities: object[], index: object[], warnings: string[],
|
|
547
|
+
* schemaless: Array<{name: string, model: string}>, colConfig: object,
|
|
548
|
+
* folder: object, recordsDirExists: boolean }>}
|
|
549
|
+
* `schemaless` lists the QUERIES whose schema resolved to nothing (the
|
|
550
|
+
* convention-default soft-skip) — their records are not pushed as entities, and
|
|
551
|
+
* the composite deploy delivers those queries statically instead.
|
|
552
|
+
* `recordsDirExists` is false for a site with no records directory at all.
|
|
553
|
+
* `sendFolder` is whether a push sends the folder — see `sendsFolder`.
|
|
540
554
|
*/
|
|
541
555
|
export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
// why `collections.yml::sync` is deleted rather than ported: "do not sync" is
|
|
548
|
-
// now "reference nothing", which is the actual round trip.
|
|
556
|
+
// ⭐ EVERY FILE IN `records/` IS A RECORD, AND EVERY RECORD IS PUSHED (ruled
|
|
557
|
+
// 2026-09-21 [Diego]). Placing a file in the directory is what makes it one —
|
|
558
|
+
// the file-side counterpart of placing a ref in the backend's folder — so nothing
|
|
559
|
+
// lists it and nothing leaves it out. `records.yml` only sorts records into
|
|
560
|
+
// sub-folders. A file named with a leading `_` is not read, so it is not a record.
|
|
549
561
|
//
|
|
550
|
-
// ⛔
|
|
551
|
-
// the
|
|
552
|
-
// what is
|
|
553
|
-
// cannot be wiped by deleting one — the destructive act requires affirmatively
|
|
554
|
-
// creating one, and the CLI asks before it happens.
|
|
562
|
+
// ⛔ A PUSHED RECORD IS NOT LIVE. It sits in the site's folder on the backend and
|
|
563
|
+
// is served once the SITE is published — publishing the site publishes its folder
|
|
564
|
+
// and what is in it. Nothing here decides that for a record.
|
|
555
565
|
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
566
|
+
// ⛔ UNTIL 2026-09-21 THE SET WAS `records.yml`'s: an unlisted entity was not
|
|
567
|
+
// pushed, and a missing `records.yml` pushed nothing. The inert state moved with
|
|
568
|
+
// the membership: a site with NO RECORDS DIRECTORY sends no folder and leaves the
|
|
569
|
+
// backend's untouched (`recordsDirExists`), so a site whose records live only on
|
|
570
|
+
// the backend is not emptied by a push of its pages.
|
|
571
|
+
//
|
|
572
|
+
// ⛔ AND THE SET IS NOT A QUERY'S. This walked the queries until 2026-09-21 and
|
|
573
|
+
// mapped each query's records, so two queries over one schema mapped every record
|
|
574
|
+
// twice and the duplicate check refused the push (measured: "appears in more than
|
|
575
|
+
// one query"), and a record no query read was never pushed. Records are walked
|
|
576
|
+
// once, by the schema their folder declares.
|
|
577
|
+
const pool = await readEntityPool(siteRoot)
|
|
561
578
|
const recordsCfg = await readRecordsConfig(siteRoot)
|
|
562
579
|
if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
|
|
563
|
-
const
|
|
580
|
+
const folder = resolveFolder(recordsCfg.entries, pool.entities, { dir: pool.dir })
|
|
581
|
+
if (folder.errors.length) {
|
|
582
|
+
throw new Error(`uwx/records: ${RECORDS_YML_RELPATH} is invalid —\n ${folder.errors.join('\n ')}`)
|
|
583
|
+
}
|
|
584
|
+
const warnings = [...pool.errors, ...folder.warnings]
|
|
564
585
|
|
|
565
586
|
const colConfig = opts.queriesConfig || (await resolveQueriesConfig(siteRoot))
|
|
566
|
-
const
|
|
567
|
-
|
|
587
|
+
const queries = siteQueries(colConfig.declarations)
|
|
588
|
+
const poolBySchema = groupPoolBySchema(pool.entities)
|
|
589
|
+
if (poolBySchema.size === 0 && queries.length === 0) {
|
|
590
|
+
return {
|
|
591
|
+
entities: [],
|
|
592
|
+
index: [],
|
|
593
|
+
warnings,
|
|
594
|
+
schemaless: [],
|
|
595
|
+
colConfig,
|
|
596
|
+
folder: { ...folder, nodes: [] },
|
|
597
|
+
recordsDirExists: pool.exists,
|
|
598
|
+
sendFolder: sendsFolder(pool, []),
|
|
599
|
+
}
|
|
600
|
+
}
|
|
568
601
|
|
|
569
602
|
// A Model declaration comes from a LOCAL foundation (offline) or, for a
|
|
570
603
|
// non-local Model, from the injected async `resolveModel(name)` — the verb wires
|
|
571
604
|
// that to the backend's Model-read route (declaration form). The local
|
|
572
605
|
// foundation is required ONLY when no resolver is provided.
|
|
573
606
|
const resolveModel = typeof opts.resolveModel === 'function' ? opts.resolveModel : null
|
|
574
|
-
// The local foundation is REQUIRED only when
|
|
575
|
-
//
|
|
576
|
-
//
|
|
577
|
-
//
|
|
578
|
-
const
|
|
607
|
+
// The local foundation is REQUIRED only when a query asked for a schema
|
|
608
|
+
// EXPLICITLY (and there's no remote resolver). A schema that only a folder name
|
|
609
|
+
// or a query name supplied soft-skips when nothing resolves, so a delivery-only
|
|
610
|
+
// site with no foundation must not be forced to have one.
|
|
611
|
+
const explicitBy = new Map() // schema (as written) → the first query that asked for it
|
|
612
|
+
for (const { name, decl } of queries) {
|
|
613
|
+
if (decl.schemaExplicit && !explicitBy.has(decl.schema)) explicitBy.set(decl.schema, name)
|
|
614
|
+
}
|
|
579
615
|
const localSchema = loadLocalFoundationSchema(siteRoot, opts, {
|
|
580
|
-
required: !resolveModel &&
|
|
616
|
+
required: !resolveModel && explicitBy.size > 0,
|
|
581
617
|
})
|
|
582
618
|
|
|
583
619
|
const declCache = new Map()
|
|
@@ -590,35 +626,6 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
590
626
|
return declaration
|
|
591
627
|
}
|
|
592
628
|
|
|
593
|
-
const sourceLocale =
|
|
594
|
-
opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
|
|
595
|
-
|
|
596
|
-
// Target locales for wrapping localized record fields per-locale: those with a
|
|
597
|
-
// structural-translation file (locales/records/{locale}.json) UNIONED with
|
|
598
|
-
// those that only have a free-form override dir (locales/freeform/{locale}/) — a
|
|
599
|
-
// record localized solely by a free-form body would otherwise go undiscovered.
|
|
600
|
-
const targetLocales = [
|
|
601
|
-
...new Set([...discoverLocales(siteRoot, 'records'), ...discoverFreeformLocales(siteRoot)]),
|
|
602
|
-
].filter((l) => l !== sourceLocale)
|
|
603
|
-
const translations =
|
|
604
|
-
targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales, 'records') : null
|
|
605
|
-
|
|
606
|
-
const entities = []
|
|
607
|
-
const index = []
|
|
608
|
-
const warnings = []
|
|
609
|
-
|
|
610
|
-
// ⭐ THE POOL, READ ONCE. A query names a `schema:` and the entities of that
|
|
611
|
-
// schema are its records — so the pool is walked once here rather than a
|
|
612
|
-
// directory per declaration, and two queries over one schema read one set of
|
|
613
|
-
// files instead of two.
|
|
614
|
-
const pool = await readEntityPool(siteRoot)
|
|
615
|
-
const poolBySchema = groupPoolBySchema(pool.entities)
|
|
616
|
-
|
|
617
|
-
const folder = resolveFolder(recordsCfg.entries, pool.entities)
|
|
618
|
-
if (folder.errors.length) {
|
|
619
|
-
throw new Error(`uwx/records: ${RECORDS_YML_RELPATH} is invalid —\n ${folder.errors.join('\n ')}`)
|
|
620
|
-
}
|
|
621
|
-
|
|
622
629
|
// ⛔ `@/x` IS A FOUNDATION-RELATIVE ALIAS AND MUST BE RESOLVED BEFORE IT SHIPS.
|
|
623
630
|
//
|
|
624
631
|
// `register` resolves it (`uwx/registry-package.js` builds `scoped` from the
|
|
@@ -640,78 +647,96 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
640
647
|
// ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
|
|
641
648
|
// (`site.js::queriesNested`): a query's `schema` must name exactly the Model
|
|
642
649
|
// these records are stored under, so both go through one function with one org.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
const schemaless = []
|
|
647
|
-
// The sync response is keyed per ($model, $id), so the pair must be unique
|
|
648
|
-
// within one submission (two queries over the same Model could otherwise
|
|
649
|
-
// reuse a slug).
|
|
650
|
-
const seen = new Set()
|
|
651
|
-
for (const { name, decl } of mapped) {
|
|
652
|
-
const declaredModel = decl.schema || decl.model
|
|
653
|
-
const modelName = resolveSelfScope(declaredModel, opts.org)
|
|
650
|
+
const warnedUnscoped = new Set()
|
|
651
|
+
const modelFor = (schema, where) => {
|
|
652
|
+
const modelName = resolveSelfScope(schema, opts.org)
|
|
654
653
|
// Unresolvable `@/` — no org is known. Ship it rather than throwing (a `status`
|
|
655
654
|
// probe on a never-pushed site has no org and must still count), but say so:
|
|
656
655
|
// the backend's refusal names a missing Model and cannot name this cause.
|
|
657
|
-
if (modelName ===
|
|
656
|
+
if (modelName === schema && typeof schema === 'string' && schema.startsWith('@/') && !warnedUnscoped.has(schema)) {
|
|
657
|
+
warnedUnscoped.add(schema)
|
|
658
658
|
warnings.push(
|
|
659
|
-
|
|
659
|
+
`${where}: \`${schema}\` is foundation-relative and no org is known, ` +
|
|
660
660
|
`so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
|
|
661
661
|
`Pass \`--org @handle\`, or push once so the site records its org.`
|
|
662
662
|
)
|
|
663
663
|
}
|
|
664
|
+
return modelName
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ⚠️ AN EXPLICIT SCHEMA THAT RESOLVES TO NOTHING IS THE AUTHOR'S ERROR — and a
|
|
668
|
+
// depth-2 folder has two readings worth naming. `records/person/2024/ada.md`
|
|
669
|
+
// resolves as `@person/2024` — the rule is total, so it is not ambiguous — but an
|
|
670
|
+
// author who meant "records organised by year inside the `person` schema" needs to
|
|
671
|
+
// be told what the build actually read, not only that something failed to resolve.
|
|
672
|
+
const unresolvedExplicit = (modelName, queryName) => {
|
|
673
|
+
const dirs = poolDirsForSchema(modelName)
|
|
674
|
+
const { alternative } = dirs ? poolPathReadings(dirs) : { alternative: null }
|
|
675
|
+
return new Error(
|
|
676
|
+
`uwx/records: Model "${modelName}" (query "${queryName}") could not be ` +
|
|
677
|
+
'resolved — not defined by a local foundation' +
|
|
678
|
+
(resolveModel
|
|
679
|
+
? ', and the backend has no such Model (register it first).'
|
|
680
|
+
: '. Run via `uniweb sync` (which fetches non-local Models from the ' +
|
|
681
|
+
'registry), or provide a local foundation that defines it.') +
|
|
682
|
+
(alternative
|
|
683
|
+
? ` If you meant \`${pool.dir}/${dirs[0]}/\` (${alternative}) organised by ` +
|
|
684
|
+
`\`${dirs[1]}\`, note that a folder inside a schema folder is read as an ` +
|
|
685
|
+
`org scope. Organise records in records.yml, not on disk.`
|
|
686
|
+
: '')
|
|
687
|
+
)
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const sourceLocale =
|
|
691
|
+
opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
|
|
692
|
+
|
|
693
|
+
// Target locales for wrapping localized record fields per-locale: those with a
|
|
694
|
+
// structural-translation file (locales/records/{locale}.json) UNIONED with
|
|
695
|
+
// those that only have a free-form override dir (locales/freeform/{locale}/) — a
|
|
696
|
+
// record localized solely by a free-form body would otherwise go undiscovered.
|
|
697
|
+
const targetLocales = [
|
|
698
|
+
...new Set([...discoverLocales(siteRoot, 'records'), ...discoverFreeformLocales(siteRoot)]),
|
|
699
|
+
].filter((l) => l !== sourceLocale)
|
|
700
|
+
const translations =
|
|
701
|
+
targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales, 'records') : null
|
|
702
|
+
|
|
703
|
+
// ⭐ THE FILE'S `$uuid` IS OURS; THE WIRE'S IS THE BACKEND'S (2026-09-20).
|
|
704
|
+
//
|
|
705
|
+
// A record's `$uuid` is its stable identity and travels with the file, so moving
|
|
706
|
+
// or renaming it changes nothing. What goes on the WIRE to backend B is the uuid B
|
|
707
|
+
// minted for it — looked up in `sync.json::backends.<B>.records` — or none, so B
|
|
708
|
+
// mints one. ⛔ Never send a backend a uuid it did not mint: whether it would
|
|
709
|
+
// accept one is the backend's to say, and this is correct either way.
|
|
710
|
+
//
|
|
711
|
+
// For the FIRST backend a record reaches, B's minted uuid is written into the
|
|
712
|
+
// file and the map is identity — exactly what happened before this change, so a
|
|
713
|
+
// single-backend project behaves as it always did.
|
|
714
|
+
const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
|
|
715
|
+
|
|
716
|
+
const entities = []
|
|
717
|
+
const index = []
|
|
718
|
+
// pool id (the FILE) → the records it produced, so the folder places records.
|
|
719
|
+
const producedBy = new Map()
|
|
720
|
+
// `draft: true` records found on the way — the push is refused if there are any.
|
|
721
|
+
const drafts = []
|
|
722
|
+
// Schemas (as written) whose Model resolved to nothing — soft-skipped.
|
|
723
|
+
const unresolved = new Set()
|
|
724
|
+
// The sync response is keyed per ($model, $id), so the pair must be unique
|
|
725
|
+
// within one submission.
|
|
726
|
+
const seen = new Set()
|
|
727
|
+
|
|
728
|
+
for (const [schema, poolEntities] of poolBySchema) {
|
|
729
|
+
const label = poolEntities[0].dirs.join('/')
|
|
730
|
+
const modelName = modelFor(schema, `${pool.dir}/${label}/`)
|
|
664
731
|
const declaration = await declarationFor(modelName)
|
|
665
732
|
if (!declaration) {
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
// ⛔ Deliberately NOT a `warnings` string. This is a product decision the
|
|
671
|
-
// author is making — entities or static files — and it needs to be
|
|
672
|
-
// reported at a prominence a prose warning cannot carry. Callers get the
|
|
673
|
-
// structured entry and say it themselves (`cli/src/commands/{publish,push}.js`).
|
|
674
|
-
//
|
|
675
|
-
// It used to push `"… — not synced"`, printed dim among everything else.
|
|
676
|
-
// That was misleading in the expensive direction: the data IS delivered,
|
|
677
|
-
// as static files. An author read "not synced" as "my data did not
|
|
678
|
-
// upload" — or skimmed it — and either way could not act on it.
|
|
679
|
-
schemaless.push({ name, model: modelName })
|
|
680
|
-
continue
|
|
681
|
-
}
|
|
682
|
-
// ⚠️ NAME BOTH READINGS OF A DEPTH-2 POOL PATH. `entities/person/2024/ada.md`
|
|
683
|
-
// resolves as `@person/2024` — the rule is total, so it is not ambiguous —
|
|
684
|
-
// but an author who meant "records organised by year inside the `person`
|
|
685
|
-
// schema" needs to be told what the build actually read, not only that
|
|
686
|
-
// something failed to resolve. The wrong reading is the plausible one.
|
|
687
|
-
const dirs = poolDirsForSchema(modelName)
|
|
688
|
-
const { alternative } = dirs ? poolPathReadings(dirs) : { alternative: null }
|
|
689
|
-
throw new Error(
|
|
690
|
-
`uwx/records: Model "${modelName}" (query "${name}") could not be ` +
|
|
691
|
-
'resolved — not defined by a local foundation' +
|
|
692
|
-
(resolveModel
|
|
693
|
-
? ', and the backend has no such Model (register it first).'
|
|
694
|
-
: '. Run via `uniweb sync` (which fetches non-local Models from the ' +
|
|
695
|
-
'registry), or provide a local foundation that defines it.') +
|
|
696
|
-
(alternative
|
|
697
|
-
? ` If you meant \`entities/${dirs[0]}/\` (${alternative}) organised by ` +
|
|
698
|
-
`\`${dirs[1]}\`, note that a folder inside a schema folder is read as an ` +
|
|
699
|
-
`org scope. Organise records in records.yml, not on disk.`
|
|
700
|
-
: '')
|
|
701
|
-
)
|
|
702
|
-
}
|
|
703
|
-
const poolEntities = loadSourceRecordsFromPool(poolBySchema, decl, folder.placements)
|
|
704
|
-
if (poolEntities == null) {
|
|
705
|
-
// No entities of this schema on disk. A remote (`url:`) query has none by
|
|
706
|
-
// definition; a file-based one with an empty pool is an author state worth
|
|
707
|
-
// naming, because "nothing synced" and "nothing there" look identical.
|
|
708
|
-
warnings.push(
|
|
709
|
-
decl.url
|
|
710
|
-
? `${name}: a remote (\`url:\`) query has no local entities — skipped`
|
|
711
|
-
: `${name}: no entities of ${decl.schema} in the pool — nothing to sync`
|
|
712
|
-
)
|
|
733
|
+
// An explicit schema the author asked for is a hard error; one only the
|
|
734
|
+
// folder's name supplied is a soft skip, reported below.
|
|
735
|
+
if (explicitBy.has(schema)) throw unresolvedExplicit(modelName, explicitBy.get(schema))
|
|
736
|
+
unresolved.add(schema)
|
|
713
737
|
continue
|
|
714
738
|
}
|
|
739
|
+
|
|
715
740
|
// Flatten source records into the mapper's flat shape; the markdown body
|
|
716
741
|
// rides under `$body` (the mapper maps it to the brief's content body field).
|
|
717
742
|
// Keep a per-slug pointer back to the source file for `$uuid` write-back —
|
|
@@ -720,39 +745,32 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
720
745
|
const flat = []
|
|
721
746
|
const sourceBySlug = new Map()
|
|
722
747
|
for (const pooled of poolEntities) {
|
|
748
|
+
const produced = []
|
|
723
749
|
for (const r of await readEntityFile(pooled.absPath)) {
|
|
724
750
|
if (!r.slug) {
|
|
725
|
-
warnings.push(`${
|
|
751
|
+
warnings.push(`${pooled.relPath}: a record without a slug was skipped`)
|
|
752
|
+
continue
|
|
753
|
+
}
|
|
754
|
+
if (isDraftRecord(r.data, pooled.relPath)) {
|
|
755
|
+
drafts.push(r.multiRecord ? `${pooled.relPath} (${r.slug})` : pooled.relPath)
|
|
726
756
|
continue
|
|
727
757
|
}
|
|
728
758
|
const rec = { ...r.data, slug: r.slug }
|
|
729
759
|
if (r.body !== undefined) rec.$body = r.body
|
|
730
|
-
// ⭐ THE RECORD'S IDENTITY IS ITS
|
|
731
|
-
//
|
|
732
|
-
// the
|
|
733
|
-
// mint two identities for one file. `<dirs>/<slug>` is unique by
|
|
734
|
-
// construction and derivable on both sides.
|
|
760
|
+
// ⭐ THE RECORD'S IDENTITY IS ITS POSITION IN THE DIRECTORY — `<dirs>/<slug>`,
|
|
761
|
+
// unique by construction and derivable on both sides. The folder must
|
|
762
|
+
// reference the very records the payload carries.
|
|
735
763
|
//
|
|
736
764
|
// ⚠️ A multi-record file (array YAML, BibTeX) contributes several records
|
|
737
765
|
// from one path, so the slug — not the file stem — completes the id.
|
|
738
766
|
rec.$id = rec.$id || [...pooled.dirs, r.slug].join('/')
|
|
739
767
|
flat.push(rec)
|
|
740
768
|
sourceBySlug.set(r.slug, r)
|
|
769
|
+
produced.push({ id: rec.$id, slug: r.slug })
|
|
741
770
|
}
|
|
771
|
+
producedBy.set(pooled.id, produced)
|
|
742
772
|
}
|
|
743
773
|
|
|
744
|
-
// ⭐ THE FILE'S `$uuid` IS OURS; THE WIRE'S IS THE BACKEND'S (2026-09-20).
|
|
745
|
-
//
|
|
746
|
-
// A record's `$uuid` is its stable identity and travels with the file, so moving
|
|
747
|
-
// or renaming it changes nothing. What goes on the WIRE to backend B is the uuid B
|
|
748
|
-
// minted for it — looked up in `sync.json::backends.<B>.records` — or none, so B
|
|
749
|
-
// mints one. ⛔ Never send a backend a uuid it did not mint: whether it would
|
|
750
|
-
// accept one is the backend's to say, and this is correct either way.
|
|
751
|
-
//
|
|
752
|
-
// For the FIRST backend a record reaches, B's minted uuid is written into the
|
|
753
|
-
// file and the map is identity — exactly what happened before this change, so a
|
|
754
|
-
// single-backend project behaves as it always did.
|
|
755
|
-
const recordMap = opts.backend ? readBackendState(siteRoot, opts.backend).records || {} : {}
|
|
756
774
|
const ownIds = new Map()
|
|
757
775
|
const onWire = flat.map((rec) => {
|
|
758
776
|
const own = typeof rec.$uuid === 'string' && rec.$uuid ? rec.$uuid : null
|
|
@@ -763,7 +781,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
763
781
|
})
|
|
764
782
|
|
|
765
783
|
const mappedOut = recordsToEntities({
|
|
766
|
-
|
|
784
|
+
label,
|
|
767
785
|
records: onWire,
|
|
768
786
|
declaration,
|
|
769
787
|
sourceLocale,
|
|
@@ -774,7 +792,6 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
774
792
|
if (targetLocales.length > 0) {
|
|
775
793
|
await applyFreeformRecordOverrides({
|
|
776
794
|
entities: mappedOut.entities,
|
|
777
|
-
queryName: name,
|
|
778
795
|
declaration,
|
|
779
796
|
sourceLocale,
|
|
780
797
|
targetLocales,
|
|
@@ -786,8 +803,8 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
786
803
|
if (seen.has(dupKey)) {
|
|
787
804
|
throw new Error(
|
|
788
805
|
`uwx/records: duplicate ($model, $id) in one sync — "${e.id}" of ` +
|
|
789
|
-
`${e.model}
|
|
790
|
-
'
|
|
806
|
+
`${e.model} comes from more than one record. Each record in a schema ` +
|
|
807
|
+
'folder needs its own slug; make the slugs unique.'
|
|
791
808
|
)
|
|
792
809
|
}
|
|
793
810
|
seen.add(dupKey)
|
|
@@ -817,9 +834,94 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
817
834
|
warnings.push(...mappedOut.warnings)
|
|
818
835
|
}
|
|
819
836
|
|
|
820
|
-
|
|
837
|
+
// ⛔ A DRAFT IS REFUSED, NOT PUSHED AND NOT SKIPPED. It is a record that stays in the
|
|
838
|
+
// folder but is not delivered — which a backend holds as the entity's `disabled`, a
|
|
839
|
+
// state this producer cannot send yet. Pushed without it, the backend would serve the
|
|
840
|
+
// draft once the site is published; skipped, it would leave the folder — and a site
|
|
841
|
+
// whose records were all drafts would send no folder at all, leaving the backend's
|
|
842
|
+
// live. So the push stops and says which records, until the flag can travel.
|
|
843
|
+
if (drafts.length) {
|
|
844
|
+
throw new Error(
|
|
845
|
+
`uwx/records: a push cannot mark a record as a draft yet, and the backend would serve it ` +
|
|
846
|
+
`once the site is published —\n ${drafts.join('\n ')}\n` +
|
|
847
|
+
' Push them as records by removing `draft: true`, or keep them off the backend by ' +
|
|
848
|
+
'starting their names with `_`.'
|
|
849
|
+
)
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// Queries whose schema resolved to nothing — not pushed as entities. The composite
|
|
853
|
+
// deploy delivers these statically (the "data ball") instead, so the caller can
|
|
854
|
+
// route them there.
|
|
855
|
+
//
|
|
856
|
+
// ⛔ Deliberately NOT a `warnings` string. This is a product decision the author
|
|
857
|
+
// is making — entities or static files — and it needs to be reported at a
|
|
858
|
+
// prominence a prose warning cannot carry. Callers get the structured entry and
|
|
859
|
+
// say it themselves (`cli/src/commands/{publish,push}.js`). It used to push
|
|
860
|
+
// `"… — not synced"`, printed dim among everything else, and an author read that
|
|
861
|
+
// as "my data did not upload" when it is delivered, as static files.
|
|
862
|
+
const schemaless = []
|
|
863
|
+
for (const { name, decl } of queries) {
|
|
864
|
+
const schema = decl.schema || decl.model
|
|
865
|
+
const hasRecords = poolBySchema.has(schema)
|
|
866
|
+
const resolved = hasRecords
|
|
867
|
+
? !unresolved.has(schema)
|
|
868
|
+
: Boolean(await declarationFor(modelFor(schema, `query "${name}"`)))
|
|
869
|
+
if (!resolved) {
|
|
870
|
+
if (decl.schemaExplicit) throw unresolvedExplicit(resolveSelfScope(schema, opts.org), name)
|
|
871
|
+
schemaless.push({ name, model: resolveSelfScope(schema, opts.org) })
|
|
872
|
+
continue
|
|
873
|
+
}
|
|
874
|
+
if (!hasRecords) {
|
|
875
|
+
// "Nothing pushed" and "nothing there" look identical, so say which.
|
|
876
|
+
const dirs = poolDirsForSchema(schema)
|
|
877
|
+
warnings.push(
|
|
878
|
+
`query "${name}": ${dirs ? `${pool.dir}/${dirs.join('/')}/` : pool.dir + '/'} holds no records of ${schema} — ` +
|
|
879
|
+
'nothing to push for it.'
|
|
880
|
+
)
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
// Records whose schema resolved to nothing and that no query reads: they are not
|
|
884
|
+
// pushed, and no static file carries them either — say so, once per folder.
|
|
885
|
+
for (const schema of unresolved) {
|
|
886
|
+
if (queries.some(({ decl }) => (decl.schema || decl.model) === schema)) continue
|
|
887
|
+
const n = poolBySchema.get(schema).length
|
|
888
|
+
warnings.push(
|
|
889
|
+
`${pool.dir}/${poolBySchema.get(schema)[0].dirs.join('/')}/: no data schema resolves for ${schema}, ` +
|
|
890
|
+
`and no query reads ${n === 1 ? 'this record' : `these ${n} records`} — not pushed.`
|
|
891
|
+
)
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const nodes = placeProducedRecords(folder.nodes, producedBy)
|
|
895
|
+
|
|
896
|
+
return {
|
|
897
|
+
entities,
|
|
898
|
+
index,
|
|
899
|
+
warnings,
|
|
900
|
+
schemaless,
|
|
901
|
+
colConfig,
|
|
902
|
+
folder: { ...folder, nodes },
|
|
903
|
+
recordsDirExists: pool.exists,
|
|
904
|
+
sendFolder: sendsFolder(pool, entities),
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Whether this push sends the site's folder — which REPLACES the backend's.
|
|
909
|
+
//
|
|
910
|
+
// ⭐ Only when the file side has a folder to state: some record was produced, or
|
|
911
|
+
// the records directory is there and holds nothing at all (the deliberate empty,
|
|
912
|
+
// which removes what the backend has — the CLI asks first).
|
|
913
|
+
//
|
|
914
|
+
// ⛔ NOT when every record's schema resolved to nothing, nor when the directory
|
|
915
|
+
// holds only files that are not records (warned). Neither is an author emptying
|
|
916
|
+
// the folder, and sending one would remove the backend's records — including any
|
|
917
|
+
// authored there — for a state nobody chose. Inert instead, like no directory.
|
|
918
|
+
function sendsFolder(pool, entities) {
|
|
919
|
+
if (!pool.exists) return false
|
|
920
|
+
if (entities.length > 0) return true
|
|
921
|
+
return pool.entities.length === 0 && pool.errors.length === 0
|
|
821
922
|
}
|
|
822
923
|
|
|
924
|
+
|
|
823
925
|
/**
|
|
824
926
|
* "Send only changed" filter, shared by the collection and combined sync paths.
|
|
825
927
|
* Hashes each entity's content (identity-independent — `$uuid`/`$id` stripped,
|
|
@@ -864,19 +966,13 @@ export function filterChanged(entities, index, { priorHashes = {}, sendAll = fal
|
|
|
864
966
|
* skipped: number }>}
|
|
865
967
|
*/
|
|
866
968
|
export async function emitRecordSyncPackage(siteRoot, opts = {}) {
|
|
867
|
-
const { entities, index, warnings,
|
|
868
|
-
if (mappedCount === 0) {
|
|
869
|
-
throw new Error(
|
|
870
|
-
'uwx/records: no query declares a schema — nothing to export. ' +
|
|
871
|
-
'Add a query to queries.yml naming the schema its records use, e.g.\n' +
|
|
872
|
-
" articles:\n schema: '@/article'"
|
|
873
|
-
)
|
|
874
|
-
}
|
|
969
|
+
const { entities, index, warnings, recordsDirExists } = await buildRecordEntities(siteRoot, opts)
|
|
875
970
|
if (entities.length === 0) {
|
|
876
971
|
throw new Error(
|
|
877
|
-
'uwx/records: no records to export
|
|
878
|
-
|
|
879
|
-
|
|
972
|
+
'uwx/records: no records to export — ' +
|
|
973
|
+
(recordsDirExists
|
|
974
|
+
? 'the records directory holds none whose data schema resolves.'
|
|
975
|
+
: 'the site has no records directory. Every file in `records/<schema>/` is a record.')
|
|
880
976
|
)
|
|
881
977
|
}
|
|
882
978
|
|