@uniweb/build 0.29.1 → 0.30.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 +6 -6
- package/src/dev-backend.js +31 -31
- package/src/i18n/freeform.js +44 -24
- package/src/i18n/index.js +22 -22
- package/src/i18n/{collections.js → records.js} +114 -51
- package/src/i18n/sync.js +9 -8
- package/src/site/build-site-data.js +9 -12
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +25 -40
- package/src/site/data-fetcher.js +23 -10
- package/src/site/entity-pool.js +211 -0
- package/src/site/fetch-shapes.js +13 -12
- package/src/site/foundation-ref.js +1 -1
- package/src/site/index.js +4 -4
- package/src/site/plugin.js +58 -63
- package/src/site/queries-config.js +324 -0
- package/src/site/{collection-processor.js → query-processor.js} +180 -95
- package/src/site/records-config.js +299 -0
- package/src/site/schemaless-data.js +2 -2
- package/src/utils/numeric-prefix.js +63 -0
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/data-schema.js +2 -2
- package/src/uwx/entity-source.js +122 -0
- package/src/uwx/folder.js +85 -77
- package/src/uwx/index.js +33 -13
- package/src/uwx/locale-sync.js +2 -2
- package/src/uwx/project-writer.js +36 -10
- package/src/uwx/queries-config.js +11 -0
- package/src/uwx/records-project.js +535 -0
- package/src/uwx/{collections.js → records.js} +152 -69
- package/src/uwx/site-diff.js +6 -6
- package/src/uwx/site-project.js +4 -4
- package/src/uwx/site.js +143 -22
- package/src/uwx/sync-package.js +32 -18
- package/src/validate-data.js +17 -19
- package/src/site/collections-config.js +0 -260
- package/src/uwx/collection-source.js +0 -180
- package/src/uwx/collections-config.js +0 -9
- package/src/uwx/collections-project.js +0 -335
- /package/src/search/{collections.js → records-index.js} +0 -0
|
@@ -44,15 +44,22 @@ import yaml from 'js-yaml'
|
|
|
44
44
|
import { detectFoundationType } from '../site/foundation-ref.js'
|
|
45
45
|
import { join, resolve } from 'node:path'
|
|
46
46
|
|
|
47
|
-
import {
|
|
48
|
-
import {
|
|
47
|
+
import { resolveQueriesConfig } from './queries-config.js'
|
|
48
|
+
import { readEntityFile } from './entity-source.js'
|
|
49
|
+
import { readRecordsConfig, resolveFolder, RECORDS_YML_RELPATH } from '../site/records-config.js'
|
|
50
|
+
import {
|
|
51
|
+
readEntityPool,
|
|
52
|
+
groupPoolBySchema,
|
|
53
|
+
poolPathReadings,
|
|
54
|
+
poolDirsForSchema,
|
|
55
|
+
} from '../site/entity-pool.js'
|
|
49
56
|
import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
|
|
50
57
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
51
58
|
import { sha256Hex, toJsonBuffer } from './manifest.js'
|
|
52
59
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
53
60
|
import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
|
|
54
61
|
import { localizeScalar, localizeContentDoc, loadLocaleTranslations, discoverLocales, discoverFreeformLocales, localesDir, isLocalizedContent } from './locale-sync.js'
|
|
55
|
-
import {
|
|
62
|
+
import { loadFreeformRecord } from '../i18n/freeform.js'
|
|
56
63
|
|
|
57
64
|
const DATE_KINDS = new Set(['date', 'datetime'])
|
|
58
65
|
// Identity/transport keys on a source record — never Model fields, never warned.
|
|
@@ -139,7 +146,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
139
146
|
if (isProseMirrorField(field)) {
|
|
140
147
|
// markdown source → ProseMirror doc. When localized, wrap per-locale exactly
|
|
141
148
|
// like a page section's content (source doc + target structural maps) — same
|
|
142
|
-
// path, flushed to locales/
|
|
149
|
+
// path, flushed to locales/records/{locale}.json by the caller.
|
|
143
150
|
const doc = typeof value === 'string' ? markdownToProseMirror(value) : value
|
|
144
151
|
if (!field.localized) return doc
|
|
145
152
|
const localized = localizeContentDoc(doc, sourceLocale, Object.keys(translations || {}), translations)
|
|
@@ -153,7 +160,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
153
160
|
// A markup `text` BODY (format markdown|html) rides as a RAW string, wrapped
|
|
154
161
|
// per-locale wholesale (its per-string translations live in the i18n manifest /
|
|
155
162
|
// free-form, not the scalar map). Other localized scalars wrap per-string from
|
|
156
|
-
// locales/
|
|
163
|
+
// locales/records/{locale}.json.
|
|
157
164
|
return isMarkupTextField(field)
|
|
158
165
|
? localize(value, sourceLocale)
|
|
159
166
|
: localizeScalar(value, sourceLocale, translations)
|
|
@@ -174,7 +181,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
174
181
|
* already carries `$uuid` (back-filled from a prior sync) round-trips it.
|
|
175
182
|
*
|
|
176
183
|
* @param {object} params
|
|
177
|
-
* @param {string} params.
|
|
184
|
+
* @param {string} params.queryName - the site.yml collection name
|
|
178
185
|
* @param {object[]} params.records - [{ slug, ...fields }]
|
|
179
186
|
* @param {object} params.declaration - the `@uniweb/data-schema` declaration
|
|
180
187
|
* (from toDataSchemaDeclaration): `{ name, brief, sections }`
|
|
@@ -184,15 +191,15 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
|
|
|
184
191
|
* @returns {{ entities: object[], warnings: string[] }} each entity is
|
|
185
192
|
* `{ id, uuid, model, file, document }` — `document` is the section-keyed body.
|
|
186
193
|
*/
|
|
187
|
-
export function
|
|
188
|
-
|
|
194
|
+
export function recordsToEntities({
|
|
195
|
+
queryName,
|
|
189
196
|
records,
|
|
190
197
|
declaration,
|
|
191
198
|
sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale,
|
|
192
199
|
translations,
|
|
193
200
|
}) {
|
|
194
201
|
if (!declaration || !declaration.name) {
|
|
195
|
-
throw new Error('uwx/
|
|
202
|
+
throw new Error('uwx/records: a declaration with a name is required')
|
|
196
203
|
}
|
|
197
204
|
// A record (one source file) maps to the Model's SINGLE sections in declared
|
|
198
205
|
// order — the brief (the card) plus any sibling single sections, e.g. a body
|
|
@@ -205,7 +212,7 @@ export function collectionRecordsToEntities({
|
|
|
205
212
|
const briefEntry = sectionEntries.find(([, s]) => s && s.brief === true)
|
|
206
213
|
const briefName = briefEntry?.[0]
|
|
207
214
|
if (!briefName) {
|
|
208
|
-
throw new Error(`uwx/
|
|
215
|
+
throw new Error(`uwx/records: Model ${declaration.name} has no brief section`)
|
|
209
216
|
}
|
|
210
217
|
// The single sections one record can populate (the brief + sibling singles).
|
|
211
218
|
//
|
|
@@ -254,7 +261,7 @@ export function collectionRecordsToEntities({
|
|
|
254
261
|
const warnings = []
|
|
255
262
|
if (contentMatches.length > 1) {
|
|
256
263
|
warnings.push(
|
|
257
|
-
`${
|
|
264
|
+
`${queryName}: ${declaration.name} has more than one content ` +
|
|
258
265
|
`(markdown / html / prosemirror) field — the markdown body maps to ` +
|
|
259
266
|
`"${bodyTarget.secName}.${bodyTarget.key}"`
|
|
260
267
|
)
|
|
@@ -262,13 +269,13 @@ export function collectionRecordsToEntities({
|
|
|
262
269
|
for (const record of records || []) {
|
|
263
270
|
const slug = record.slug
|
|
264
271
|
if (!slug) {
|
|
265
|
-
warnings.push(`${
|
|
272
|
+
warnings.push(`${queryName}: a record without a slug was skipped`)
|
|
266
273
|
continue
|
|
267
274
|
}
|
|
268
275
|
// `$id` is the payload-local handle = the record's path under collections/
|
|
269
276
|
// (`<collection>/<slug>`), globally unique within one sync so the @uniweb/folder
|
|
270
277
|
// entity can point a leaf at it via `$ref`. An explicit frontmatter `$id` wins.
|
|
271
|
-
const id = record.$id || `${
|
|
278
|
+
const id = record.$id || `${queryName}/${slug}`
|
|
272
279
|
const uuid = record.$uuid || null
|
|
273
280
|
const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
|
|
274
281
|
|
|
@@ -297,13 +304,13 @@ export function collectionRecordsToEntities({
|
|
|
297
304
|
for (const key of Object.keys(record)) {
|
|
298
305
|
if (SKIP_KEYS.has(key) || fieldByKey.has(key)) continue
|
|
299
306
|
warnings.push(
|
|
300
|
-
`${
|
|
307
|
+
`${queryName}/${slug}: field "${key}" is not on ` +
|
|
301
308
|
`${declaration.name} — not synced`
|
|
302
309
|
)
|
|
303
310
|
}
|
|
304
311
|
if (hasBody && !bodyTarget) {
|
|
305
312
|
warnings.push(
|
|
306
|
-
`${
|
|
313
|
+
`${queryName}/${slug}: markdown body present but ` +
|
|
307
314
|
`${declaration.name} has no content body field — body not synced`
|
|
308
315
|
)
|
|
309
316
|
}
|
|
@@ -324,9 +331,8 @@ export function collectionRecordsToEntities({
|
|
|
324
331
|
id,
|
|
325
332
|
uuid,
|
|
326
333
|
slug,
|
|
327
|
-
collection: collectionName, // the @uniweb/folder groups leaves by this
|
|
328
334
|
model: declaration.name, // reference the Model BY NAME — importer resolves it
|
|
329
|
-
file: `entities/${
|
|
335
|
+
file: `entities/${queryName}/${slug}.json`,
|
|
330
336
|
document,
|
|
331
337
|
})
|
|
332
338
|
}
|
|
@@ -334,14 +340,14 @@ export function collectionRecordsToEntities({
|
|
|
334
340
|
}
|
|
335
341
|
|
|
336
342
|
// Post-pass: override a collection record's localized CONTENT body with a per-locale
|
|
337
|
-
// FREE-FORM body when `locales/freeform/{locale}/
|
|
343
|
+
// FREE-FORM body when `locales/freeform/{locale}/entities/<schema>/<slug>.md` exists
|
|
338
344
|
// — the override wins over the structural map, exactly like site-content sections
|
|
339
345
|
// (site.js localizeContentTree). Only a `format: prosemirror` localized field can
|
|
340
346
|
// take it (it is a PM doc on the wire; a markup `text` body stays a raw string).
|
|
341
347
|
// Mutates the entity documents in place. Async — the free-form read hits the disk.
|
|
342
|
-
async function
|
|
348
|
+
async function applyFreeformRecordOverrides({
|
|
343
349
|
entities,
|
|
344
|
-
|
|
350
|
+
queryName,
|
|
345
351
|
declaration,
|
|
346
352
|
sourceLocale,
|
|
347
353
|
targetLocales,
|
|
@@ -362,8 +368,8 @@ async function applyFreeformCollectionOverrides({
|
|
|
362
368
|
// still a bare doc because no structural translation was present).
|
|
363
369
|
const sourceDoc = isLocalizedContent(localized) ? localized[sourceLocale] : localized
|
|
364
370
|
for (const locale of targetLocales) {
|
|
365
|
-
//
|
|
366
|
-
const body = (await
|
|
371
|
+
// loadFreeformRecord returns { content, frontmatter, … } — doc is `.content`.
|
|
372
|
+
const body = (await loadFreeformRecord({ slug: entity.slug }, entity.model, locale, localesBase))?.content
|
|
367
373
|
if (!body) continue
|
|
368
374
|
if (!isLocalizedContent(localized)) localized = { [sourceLocale]: sourceDoc }
|
|
369
375
|
localized[locale] = body // free-form full body overrides the structural map
|
|
@@ -377,9 +383,9 @@ async function applyFreeformCollectionOverrides({
|
|
|
377
383
|
// The collections in site.yml that opt into export (an object decl with `model:`).
|
|
378
384
|
// The declared collections that opt into sync: a resolvable data schema present
|
|
379
385
|
// (explicit or convention-defaulted) and not opted out (`sync: false`). Takes the
|
|
380
|
-
// merged declarations from
|
|
386
|
+
// merged declarations from resolveQueriesConfig (collections.yml over
|
|
381
387
|
// site.yml::collections), so collections.yml is honored without re-reading.
|
|
382
|
-
function
|
|
388
|
+
function syncableQueries(declarations) {
|
|
383
389
|
const out = []
|
|
384
390
|
for (const decl of Object.values(declarations)) {
|
|
385
391
|
if ((decl.schema || decl.model) && decl.sync !== false) out.push({ name: decl.name, decl })
|
|
@@ -426,7 +432,7 @@ function loadLocalFoundationSchema(siteRoot, opts, { required }) {
|
|
|
426
432
|
if (!foundationDir) {
|
|
427
433
|
if (required) {
|
|
428
434
|
throw new Error(
|
|
429
|
-
'uwx/
|
|
435
|
+
'uwx/records: could not locate a local foundation. Pass foundationDir, ' +
|
|
430
436
|
'use a `file:` foundation dependency, or run via `uniweb sync` so non-local ' +
|
|
431
437
|
'Models resolve from the registry.'
|
|
432
438
|
)
|
|
@@ -437,7 +443,7 @@ function loadLocalFoundationSchema(siteRoot, opts, { required }) {
|
|
|
437
443
|
if (!existsSync(schemaPath)) {
|
|
438
444
|
if (required) {
|
|
439
445
|
throw new Error(
|
|
440
|
-
`uwx/
|
|
446
|
+
`uwx/records: ${schemaPath} not found — build the foundation first ` +
|
|
441
447
|
'(`uniweb build`).'
|
|
442
448
|
)
|
|
443
449
|
}
|
|
@@ -467,15 +473,28 @@ function resolveDeclaration(schema, modelName) {
|
|
|
467
473
|
return null
|
|
468
474
|
}
|
|
469
475
|
|
|
470
|
-
// Load a
|
|
476
|
+
// Load a query's ORIGINAL source records for export — the author's files,
|
|
471
477
|
// untouched (raw frontmatter + raw markdown body, raw YAML/JSON, raw BibTeX). This
|
|
472
|
-
// is deliberately NOT `
|
|
478
|
+
// is deliberately NOT `processQueries` (the delivery pipeline that builds
|
|
473
479
|
// public/data, converts bodies to ProseMirror, and copies assets). Sync carries
|
|
474
|
-
// the source.
|
|
475
|
-
//
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
480
|
+
// the source.
|
|
481
|
+
//
|
|
482
|
+
// ⭐ THE QUERY NAMES A SCHEMA AND THE POOL FOLLOWS. It does not name a directory,
|
|
483
|
+
// and there is no disk path for it to name: `entities/{schema}/` declares the
|
|
484
|
+
// model, so the entities of a schema ARE its records. That is the de-conflation —
|
|
485
|
+
// `collections/<name>/` used to answer "which files", "which schema" and "grouped
|
|
486
|
+
// how" with one directory, and only the first two were ever the same question.
|
|
487
|
+
//
|
|
488
|
+
// Remote (`url:`) queries have no local files; the caller warns and skips.
|
|
489
|
+
function loadSourceRecordsFromPool(poolBySchema, decl, placements) {
|
|
490
|
+
if (!decl.schema) return null
|
|
491
|
+
const entities = poolBySchema.get(decl.schema)
|
|
492
|
+
if (!entities) return null
|
|
493
|
+
// ⛔ ONLY WHAT `records.yml` REFERENCES. An entity of the right schema that no
|
|
494
|
+
// entry places is not a record, so syncing it would create something nobody can
|
|
495
|
+
// reach — and would make the payload disagree with the folder describing it.
|
|
496
|
+
const placed = entities.filter((e) => placements.has(e.id))
|
|
497
|
+
return placed.length ? placed : null
|
|
479
498
|
}
|
|
480
499
|
|
|
481
500
|
/**
|
|
@@ -500,15 +519,36 @@ async function loadSourceRecords(siteRoot, decl) {
|
|
|
500
519
|
* default soft-skip) — not synced as entities; the composite deploy delivers
|
|
501
520
|
* them statically via the data ball.
|
|
502
521
|
*/
|
|
503
|
-
export async function
|
|
522
|
+
export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
504
523
|
// Merged collections config (collections.yml over site.yml::collections). Reused
|
|
505
524
|
// from the caller when provided (sync-package shares it with the folder builder).
|
|
506
|
-
|
|
525
|
+
// ⭐ `records.yml` IS THE FOLDER, AND IT DECIDES WHAT SYNCS. Listing an entity
|
|
526
|
+
// is what makes it a record; an entity nothing references exists but cannot be
|
|
527
|
+
// publicly fetched — so it is a draft, for free, with no flag to set. This is
|
|
528
|
+
// why `collections.yml::sync` is deleted rather than ported: "do not sync" is
|
|
529
|
+
// now "reference nothing", which is the actual round trip.
|
|
530
|
+
//
|
|
531
|
+
// ⛔ AND `missing` IS NOT `empty`. Missing means do not sync at all and leave
|
|
532
|
+
// the server's folder untouched; empty means sync an empty folder, REMOVING
|
|
533
|
+
// what is there. The safe state is the absence of a file, so a live folder
|
|
534
|
+
// cannot be wiped by deleting one — the destructive act requires affirmatively
|
|
535
|
+
// creating one, and the CLI asks before it happens.
|
|
536
|
+
//
|
|
537
|
+
// ⚠️ READ FIRST, ABOVE EVERY EARLY RETURN. `recordsState` has to ride out of
|
|
538
|
+
// this function on every path, because its ABSENCE reads as "not missing" to a
|
|
539
|
+
// caller — measured: a site with no queries returned no state, the folder
|
|
540
|
+
// builder took that for `declared`, and a site with no `records.yml` at all
|
|
541
|
+
// emitted an empty folder that would have removed everything.
|
|
542
|
+
const recordsCfg = await readRecordsConfig(siteRoot)
|
|
543
|
+
if (recordsCfg.error) throw new Error(`uwx/records: ${recordsCfg.error}`)
|
|
544
|
+
const recordsState = recordsCfg.state
|
|
545
|
+
|
|
546
|
+
const colConfig = opts.queriesConfig || (await resolveQueriesConfig(siteRoot))
|
|
507
547
|
if (!colConfig.folderSync) {
|
|
508
|
-
return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig }
|
|
548
|
+
return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig, recordsState, folder: null }
|
|
509
549
|
}
|
|
510
|
-
const mapped =
|
|
511
|
-
if (mapped.length === 0) return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig }
|
|
550
|
+
const mapped = syncableQueries(colConfig.declarations)
|
|
551
|
+
if (mapped.length === 0) return { entities: [], index: [], warnings: [], schemaless: [], mappedCount: 0, colConfig, recordsState, folder: null }
|
|
512
552
|
|
|
513
553
|
// A Model declaration comes from a LOCAL foundation (offline) or, for a
|
|
514
554
|
// non-local Model, from the injected async `resolveModel(name)` — the verb wires
|
|
@@ -538,19 +578,31 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
538
578
|
opts.sourceLocale || LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale
|
|
539
579
|
|
|
540
580
|
// Target locales for wrapping localized record fields per-locale: those with a
|
|
541
|
-
// structural-translation file (locales/
|
|
581
|
+
// structural-translation file (locales/records/{locale}.json) UNIONED with
|
|
542
582
|
// those that only have a free-form override dir (locales/freeform/{locale}/) — a
|
|
543
583
|
// record localized solely by a free-form body would otherwise go undiscovered.
|
|
544
584
|
const targetLocales = [
|
|
545
|
-
...new Set([...discoverLocales(siteRoot, '
|
|
585
|
+
...new Set([...discoverLocales(siteRoot, 'records'), ...discoverFreeformLocales(siteRoot)]),
|
|
546
586
|
].filter((l) => l !== sourceLocale)
|
|
547
587
|
const translations =
|
|
548
|
-
targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales, '
|
|
588
|
+
targetLocales.length > 0 ? loadLocaleTranslations(siteRoot, targetLocales, 'records') : null
|
|
549
589
|
|
|
550
590
|
const entities = []
|
|
551
591
|
const index = []
|
|
552
592
|
const warnings = []
|
|
553
593
|
|
|
594
|
+
// ⭐ THE POOL, READ ONCE. A query names a `schema:` and the entities of that
|
|
595
|
+
// schema are its records — so the pool is walked once here rather than a
|
|
596
|
+
// directory per declaration, and two queries over one schema read one set of
|
|
597
|
+
// files instead of two.
|
|
598
|
+
const pool = await readEntityPool(siteRoot)
|
|
599
|
+
const poolBySchema = groupPoolBySchema(pool.entities)
|
|
600
|
+
|
|
601
|
+
const folder = resolveFolder(recordsCfg.entries, pool.entities)
|
|
602
|
+
if (folder.errors.length) {
|
|
603
|
+
throw new Error(`uwx/records: ${RECORDS_YML_RELPATH} is invalid —\n ${folder.errors.join('\n ')}`)
|
|
604
|
+
}
|
|
605
|
+
|
|
554
606
|
// ⛔ `@/x` IS A FOUNDATION-RELATIVE ALIAS AND MUST BE RESOLVED BEFORE IT SHIPS.
|
|
555
607
|
//
|
|
556
608
|
// `register` resolves it (`uwx/registry-package.js` builds `scoped` from the
|
|
@@ -590,7 +642,7 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
590
642
|
// the backend's refusal names a missing Model and cannot name this cause.
|
|
591
643
|
if (modelName === declaredModel && typeof declaredModel === 'string' && declaredModel.startsWith('@/')) {
|
|
592
644
|
warnings.push(
|
|
593
|
-
`
|
|
645
|
+
`query "${name}": \`${declaredModel}\` is foundation-relative and no org is known, ` +
|
|
594
646
|
`so it ships unresolved. The backend resolves Models by name and will refuse it. ` +
|
|
595
647
|
`Pass \`--org @handle\`, or push once so the site records its org.`
|
|
596
648
|
)
|
|
@@ -613,19 +665,36 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
613
665
|
schemaless.push({ name, model: modelName })
|
|
614
666
|
continue
|
|
615
667
|
}
|
|
668
|
+
// ⚠️ NAME BOTH READINGS OF A DEPTH-2 POOL PATH. `entities/person/2024/ada.md`
|
|
669
|
+
// resolves as `@person/2024` — the rule is total, so it is not ambiguous —
|
|
670
|
+
// but an author who meant "records organised by year inside the `person`
|
|
671
|
+
// schema" needs to be told what the build actually read, not only that
|
|
672
|
+
// something failed to resolve. The wrong reading is the plausible one.
|
|
673
|
+
const dirs = poolDirsForSchema(modelName)
|
|
674
|
+
const { alternative } = dirs ? poolPathReadings(dirs) : { alternative: null }
|
|
616
675
|
throw new Error(
|
|
617
|
-
`uwx/
|
|
676
|
+
`uwx/records: Model "${modelName}" (query "${name}") could not be ` +
|
|
618
677
|
'resolved — not defined by a local foundation' +
|
|
619
678
|
(resolveModel
|
|
620
679
|
? ', and the backend has no such Model (register it first).'
|
|
621
680
|
: '. Run via `uniweb sync` (which fetches non-local Models from the ' +
|
|
622
|
-
'registry), or provide a local foundation that defines it.')
|
|
681
|
+
'registry), or provide a local foundation that defines it.') +
|
|
682
|
+
(alternative
|
|
683
|
+
? ` If you meant \`entities/${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
|
+
: '')
|
|
623
687
|
)
|
|
624
688
|
}
|
|
625
|
-
const
|
|
626
|
-
if (
|
|
689
|
+
const poolEntities = loadSourceRecordsFromPool(poolBySchema, decl, folder.placements)
|
|
690
|
+
if (poolEntities == null) {
|
|
691
|
+
// No entities of this schema on disk. A remote (`url:`) query has none by
|
|
692
|
+
// definition; a file-based one with an empty pool is an author state worth
|
|
693
|
+
// naming, because "nothing synced" and "nothing there" look identical.
|
|
627
694
|
warnings.push(
|
|
628
|
-
|
|
695
|
+
decl.url
|
|
696
|
+
? `${name}: a remote (\`url:\`) query has no local entities — skipped`
|
|
697
|
+
: `${name}: no entities of ${decl.schema} in the pool — nothing to sync`
|
|
629
698
|
)
|
|
630
699
|
continue
|
|
631
700
|
}
|
|
@@ -636,19 +705,30 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
636
705
|
// deferred (no single-record file to rewrite in place).
|
|
637
706
|
const flat = []
|
|
638
707
|
const sourceBySlug = new Map()
|
|
639
|
-
for (const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
708
|
+
for (const pooled of poolEntities) {
|
|
709
|
+
for (const r of await readEntityFile(pooled.absPath)) {
|
|
710
|
+
if (!r.slug) {
|
|
711
|
+
warnings.push(`${name}: a record without a slug was skipped`)
|
|
712
|
+
continue
|
|
713
|
+
}
|
|
714
|
+
const rec = { ...r.data, slug: r.slug }
|
|
715
|
+
if (r.body !== undefined) rec.$body = r.body
|
|
716
|
+
// ⭐ THE RECORD'S IDENTITY IS ITS POOL POSITION, not `<query>/<slug>`. It
|
|
717
|
+
// has to be: the folder places entities and must reference the very ones
|
|
718
|
+
// the payload carries, and two queries over one schema would otherwise
|
|
719
|
+
// mint two identities for one file. `<dirs>/<slug>` is unique by
|
|
720
|
+
// construction and derivable on both sides.
|
|
721
|
+
//
|
|
722
|
+
// ⚠️ A multi-record file (array YAML, BibTeX) contributes several records
|
|
723
|
+
// from one path, so the slug — not the file stem — completes the id.
|
|
724
|
+
rec.$id = rec.$id || [...pooled.dirs, r.slug].join('/')
|
|
725
|
+
flat.push(rec)
|
|
726
|
+
sourceBySlug.set(r.slug, r)
|
|
643
727
|
}
|
|
644
|
-
const rec = { ...r.data, slug: r.slug }
|
|
645
|
-
if (r.body !== undefined) rec.$body = r.body
|
|
646
|
-
flat.push(rec)
|
|
647
|
-
sourceBySlug.set(r.slug, r)
|
|
648
728
|
}
|
|
649
729
|
|
|
650
|
-
const mappedOut =
|
|
651
|
-
|
|
730
|
+
const mappedOut = recordsToEntities({
|
|
731
|
+
queryName: name,
|
|
652
732
|
records: flat,
|
|
653
733
|
declaration,
|
|
654
734
|
sourceLocale,
|
|
@@ -657,9 +737,9 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
657
737
|
// Free-form per-locale body overrides (a full localized doc beats the structural
|
|
658
738
|
// map) — only meaningful for a multi-locale site with a prosemirror content field.
|
|
659
739
|
if (targetLocales.length > 0) {
|
|
660
|
-
await
|
|
740
|
+
await applyFreeformRecordOverrides({
|
|
661
741
|
entities: mappedOut.entities,
|
|
662
|
-
|
|
742
|
+
queryName: name,
|
|
663
743
|
declaration,
|
|
664
744
|
sourceLocale,
|
|
665
745
|
targetLocales,
|
|
@@ -670,8 +750,8 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
670
750
|
const dupKey = `${e.model} ${e.id}`
|
|
671
751
|
if (seen.has(dupKey)) {
|
|
672
752
|
throw new Error(
|
|
673
|
-
`uwx/
|
|
674
|
-
`${e.model} appears in more than one
|
|
753
|
+
`uwx/records: duplicate ($model, $id) in one sync — "${e.id}" of ` +
|
|
754
|
+
`${e.model} appears in more than one query. Each ($model, $id) ` +
|
|
675
755
|
'must be unique within a sync; make the slugs unique.'
|
|
676
756
|
)
|
|
677
757
|
}
|
|
@@ -699,7 +779,7 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
|
|
|
699
779
|
warnings.push(...mappedOut.warnings)
|
|
700
780
|
}
|
|
701
781
|
|
|
702
|
-
return { entities, index, warnings, schemaless, mappedCount: mapped.length, colConfig }
|
|
782
|
+
return { entities, index, warnings, schemaless, mappedCount: mapped.length, colConfig, folder, recordsState }
|
|
703
783
|
}
|
|
704
784
|
|
|
705
785
|
/**
|
|
@@ -734,28 +814,31 @@ export function filterChanged(entities, index, { priorHashes = {}, sendAll = fal
|
|
|
734
814
|
|
|
735
815
|
/**
|
|
736
816
|
* Build a collection-only sync package. Thin composition over
|
|
737
|
-
* `
|
|
817
|
+
* `buildRecordEntities` + `filterChanged` + `emitEntitySyncPackage`, kept for
|
|
738
818
|
* the collection-only callers/tests. The combined site+collections path is
|
|
739
819
|
* `emitSyncPackage` (sync-package.js).
|
|
740
820
|
*
|
|
741
821
|
* @param {string} siteRoot
|
|
742
|
-
* @param {object} [opts] -
|
|
822
|
+
* @param {object} [opts] - buildRecordEntities opts, plus `priorHashes`,
|
|
743
823
|
* `sendAll`, `exporter`, `exportedAt`.
|
|
744
824
|
* @returns {Promise<{ buffer: Buffer|null, models: string[], entityCount: number,
|
|
745
825
|
* warnings: string[], index: object[], hashes: Object<string,string>,
|
|
746
826
|
* skipped: number }>}
|
|
747
827
|
*/
|
|
748
|
-
export async function
|
|
749
|
-
const { entities, index, warnings, mappedCount } = await
|
|
828
|
+
export async function emitRecordSyncPackage(siteRoot, opts = {}) {
|
|
829
|
+
const { entities, index, warnings, mappedCount } = await buildRecordEntities(siteRoot, opts)
|
|
750
830
|
if (mappedCount === 0) {
|
|
751
831
|
throw new Error(
|
|
752
|
-
'uwx/
|
|
753
|
-
'Add
|
|
832
|
+
'uwx/records: no query declares a schema — nothing to export. ' +
|
|
833
|
+
'Add a query to queries.yml naming the schema its records use, e.g.\n' +
|
|
834
|
+
" articles:\n schema: '@/article'"
|
|
754
835
|
)
|
|
755
836
|
}
|
|
756
837
|
if (entities.length === 0) {
|
|
757
838
|
throw new Error(
|
|
758
|
-
'uwx/
|
|
839
|
+
'uwx/records: no records to export. Either records.yml references ' +
|
|
840
|
+
'nothing, or every query matched an empty pool — an entity is only a ' +
|
|
841
|
+
'record once records.yml lists it.'
|
|
759
842
|
)
|
|
760
843
|
}
|
|
761
844
|
|
package/src/uwx/site-diff.js
CHANGED
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
// Either base may be missing; the affected side degrades to "unknown" and is
|
|
47
47
|
// reported as unattributed rather than guessed at.
|
|
48
48
|
|
|
49
|
-
import { entityContentHash } from './
|
|
49
|
+
import { entityContentHash } from './records.js'
|
|
50
50
|
import { recordStableId, safeStableIdFilename, pageDirName } from './site-project.js'
|
|
51
51
|
import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
|
|
52
52
|
|
|
@@ -295,20 +295,20 @@ export function describeSiteDiff(diff, { limit = 8 } = {}) {
|
|
|
295
295
|
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
|
-
*
|
|
299
|
-
* push response's `finalized[].document`): `{ <
|
|
298
|
+
* Query-declaration identity from a document the BACKEND produced (a pull, or a
|
|
299
|
+
* push response's `finalized[].document`): `{ <query name>: <$uuid> }`.
|
|
300
300
|
*
|
|
301
301
|
* ⛔ The sibling of collectFolderItemUuids, and it exists for the same reason: these
|
|
302
302
|
* items have no file of their own, so nothing in a path-keyed map can hold their
|
|
303
303
|
* identity, and a push that omits it re-sends the whole section uuid-less. The
|
|
304
|
-
* backend refuses that rather than deleting every stored row — see
|
|
304
|
+
* backend refuses that rather than deleting every stored row — see queriesNested.
|
|
305
305
|
*
|
|
306
306
|
* Keyed by `name`, which the backend enforces unique within the section. `$id` is
|
|
307
307
|
* deliberately not consulted: it is a payload-local handle the backend never stores.
|
|
308
308
|
*/
|
|
309
|
-
export function
|
|
309
|
+
export function collectQueryUuids(doc) {
|
|
310
310
|
const out = {}
|
|
311
|
-
for (const item of doc?.
|
|
311
|
+
for (const item of doc?.queries || []) {
|
|
312
312
|
const name = item?.name
|
|
313
313
|
const uuid = item?.$uuid
|
|
314
314
|
if (typeof name === 'string' && typeof uuid === 'string' && name && uuid) out[name] = uuid
|
package/src/uwx/site-project.js
CHANGED
|
@@ -38,7 +38,7 @@ import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync,
|
|
|
38
38
|
import { createHash } from 'node:crypto'
|
|
39
39
|
import yaml from 'js-yaml'
|
|
40
40
|
import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
|
|
41
|
-
import {
|
|
41
|
+
import { declarationsToQueriesYml } from './records-project.js'
|
|
42
42
|
import { authorableFetch } from '../site/fetch-shapes.js'
|
|
43
43
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
|
|
44
44
|
import { buildFreeformPath } from '../i18n/freeform.js'
|
|
@@ -628,12 +628,12 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
|
|
|
628
628
|
/**
|
|
629
629
|
* Project a whole `@uniweb/site-content` document to a site's files: `info` →
|
|
630
630
|
* config (siteInfoToConfig), `collections[]` declarations →
|
|
631
|
-
* `collections.yml::collections` (
|
|
631
|
+
* `collections.yml::collections` (declarationsToQueriesYml), `pages[]` →
|
|
632
632
|
* `pages/**`, `layout_sections` → `layout/**`. Idempotent. Matches by
|
|
633
633
|
* stableId-name (clean overwrite); orphan deletion + content-similarity matching
|
|
634
634
|
* is the reconcile layer.
|
|
635
635
|
*
|
|
636
|
-
* The collection RECORDS are the separate collections lane (
|
|
636
|
+
* The collection RECORDS are the separate collections lane (recordsToProject);
|
|
637
637
|
* this writes only their config declarations.
|
|
638
638
|
*
|
|
639
639
|
* @param {object} params
|
|
@@ -661,7 +661,7 @@ export function siteContentDocumentToProject({ document, siteRoot, sourceLocale
|
|
|
661
661
|
report.assets = restoreAssetRefs(document, readAssetMap(siteRoot))
|
|
662
662
|
|
|
663
663
|
report.config = siteInfoToConfig({ document, siteRoot, sourceLocale, collector, keepAuthoredFoundation })
|
|
664
|
-
report.
|
|
664
|
+
report.queries = declarationsToQueriesYml({ document, siteRoot })
|
|
665
665
|
|
|
666
666
|
// The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
|
|
667
667
|
// rename detection, build a fresh one as we project, then persist it. Items not
|