@uniweb/build 0.29.1 → 0.30.1

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.
Files changed (41) hide show
  1. package/package.json +5 -5
  2. package/src/content/index.js +6 -6
  3. package/src/dev-backend.js +31 -31
  4. package/src/i18n/freeform.js +44 -24
  5. package/src/i18n/index.js +22 -22
  6. package/src/i18n/{collections.js → records.js} +114 -51
  7. package/src/i18n/sync.js +9 -8
  8. package/src/site/build-site-data.js +9 -12
  9. package/src/site/config.js +1 -1
  10. package/src/site/content-collector.js +35 -40
  11. package/src/site/data-fetcher.js +23 -10
  12. package/src/site/entity-pool.js +211 -0
  13. package/src/site/fetch-shapes.js +13 -12
  14. package/src/site/foundation-ref.js +1 -1
  15. package/src/site/index.js +4 -4
  16. package/src/site/plugin.js +58 -63
  17. package/src/site/queries-config.js +324 -0
  18. package/src/site/{collection-processor.js → query-processor.js} +180 -95
  19. package/src/site/records-config.js +299 -0
  20. package/src/site/schemaless-data.js +2 -2
  21. package/src/utils/numeric-prefix.js +63 -0
  22. package/src/uwx/backfill.js +5 -5
  23. package/src/uwx/data-schema.js +2 -2
  24. package/src/uwx/entity-source.js +122 -0
  25. package/src/uwx/folder.js +92 -77
  26. package/src/uwx/index.js +33 -13
  27. package/src/uwx/locale-sync.js +2 -2
  28. package/src/uwx/project-writer.js +36 -10
  29. package/src/uwx/queries-config.js +11 -0
  30. package/src/uwx/records-project.js +535 -0
  31. package/src/uwx/{collections.js → records.js} +152 -69
  32. package/src/uwx/site-diff.js +6 -6
  33. package/src/uwx/site-project.js +30 -5
  34. package/src/uwx/site.js +295 -27
  35. package/src/uwx/sync-package.js +32 -18
  36. package/src/validate-data.js +17 -19
  37. package/src/site/collections-config.js +0 -260
  38. package/src/uwx/collection-source.js +0 -180
  39. package/src/uwx/collections-config.js +0 -9
  40. package/src/uwx/collections-project.js +0 -335
  41. /package/src/search/{collections.js → records-index.js} +0 -0
@@ -0,0 +1,535 @@
1
+ // Collections projection — write a folder + its record entities back to the
2
+ // site's `collections/**` source files. The inverse of the collections producer
3
+ // (collections.js + folder.js): the producer reads source records and emits the
4
+ // `@uniweb/folder` entity + one section-keyed `$`-document per record; this takes
5
+ // those documents back and renders them to files.
6
+ //
7
+ // Identity & placement. A record's on-disk home is `(collection, slug)`:
8
+ // - `slug` and `collection` come from the FOLDER document — each ref leaf is
9
+ // `{ entry: { model, entity: <uuid> }, path_segment: <slug> }` inside a branch
10
+ // (its `$children`) whose `path_segment` names the folder it was placed in.
11
+ // The folder is the authoritative organization on a read (the record
12
+ // document's own `$id` envelope is not guaranteed to be echoed back), with
13
+ // the record document's `$id` (its pool identity) as a fallback when present.
14
+ // - the record's directory comes from its `$model` — `entities/{schema}/` is
15
+ // where a thing of that model lives. Not from any query: a query has no
16
+ // directory, and which query selects a record is not a fact about it.
17
+ // - an existing local file carrying the same `$uuid` is re-rendered in place;
18
+ // otherwise a new single-record file is placed at `<slug>.<ext>`, its format
19
+ // matched to the collection's existing files, else markdown when the Model's
20
+ // brief has a content body field, else YAML.
21
+ //
22
+ // Field rendering reuses renderEntityDocument (via writeRecordFile) — localized
23
+ // unwrap, date handling, content-body→body are already inverted there.
24
+ //
25
+ // v1 scope / deferred: array-form & BibTeX multi-record files (a pulled record is
26
+ // placed as its own single-record file; merging into an existing array file is a
27
+ // later nicety); deriving an on-disk collection from a deeply NESTED virtual
28
+ // folder org when the record carries no `$id`; and rewriting `collections.yml`'s
29
+ // `folders:` organization + synthesizing declarations for newly-introduced collections
30
+ // (a comment-preserving config rewrite is a separate quality bar). The folder itself
31
+ // carries no `$uuid` — the backend owns it, keyed by the site-content uuid — so
32
+ // nothing is written into `collections.yml` here. Nothing is silently dropped: an
33
+ // unplaceable or unresolvable record is reported.
34
+
35
+ import { readFileSync, readdirSync, existsSync } from 'node:fs'
36
+ import { join, resolve, relative, extname, basename, sep } from 'node:path'
37
+ import yaml from 'js-yaml'
38
+ import { parseFrontmatter } from './entity-source.js'
39
+ import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
40
+ import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
41
+ import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
42
+ import { isContentBodyField } from './data-schema.js'
43
+ import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
44
+ import { buildFreeformRecordPath } from '../i18n/freeform.js'
45
+
46
+ // Single-record source extensions we scan + place (BibTeX is multi-record → out).
47
+ const EXT_FOR_FORMAT = { md: '.md', yaml: '.yml', json: '.json' }
48
+
49
+ function formatForExt(ext) {
50
+ if (ext === '.md') return 'md'
51
+ if (ext === '.yml' || ext === '.yaml') return 'yaml'
52
+ if (ext === '.json') return 'json'
53
+ return null
54
+ }
55
+
56
+ // Read the `$uuid` declared in a single-record source file, or null (array-form,
57
+ // unreadable, or no `$uuid`). Used to find an existing local file for a record.
58
+ function readFileUuid(filePath, format) {
59
+ let raw
60
+ try {
61
+ raw = readFileSync(filePath, 'utf8')
62
+ } catch {
63
+ return null
64
+ }
65
+ try {
66
+ if (format === 'md') return parseFrontmatter(raw).frontmatter?.$uuid ?? null
67
+ const parsed = format === 'json' ? JSON.parse(raw) : yaml.load(raw)
68
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
69
+ return parsed.$uuid ?? null
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Find the single-record file in `poolDir` whose `$uuid` matches, or null.
77
+ * @returns {{ path: string, format: 'md'|'yaml'|'json' }|null}
78
+ */
79
+ export function findRecordFileByUuid(poolDir, uuid) {
80
+ if (!uuid || !existsSync(poolDir)) return null
81
+ for (const entry of readdirSync(poolDir)) {
82
+ if (entry.startsWith('_')) continue
83
+ const format = formatForExt(extname(entry).toLowerCase())
84
+ if (!format) continue
85
+ const path = join(poolDir, entry)
86
+ if (readFileUuid(path, format) === uuid) return { path, format }
87
+ }
88
+ return null
89
+ }
90
+
91
+ // The format to give a NEW record file in a collection: match the collection's
92
+ // existing single-record files, else markdown when the Model's brief carries a
93
+ // content body field (so the body has a home), else YAML.
94
+ function defaultFormat(poolDir, declaration) {
95
+ if (existsSync(poolDir)) {
96
+ for (const entry of readdirSync(poolDir)) {
97
+ if (entry.startsWith('_')) continue
98
+ const format = formatForExt(extname(entry).toLowerCase())
99
+ if (format) return format
100
+ }
101
+ }
102
+ return briefHasContentBody(declaration) ? 'md' : 'yaml'
103
+ }
104
+
105
+ // Whether the declaration's brief section declares a content body field — a markup
106
+ // `text` field or a `format: prosemirror` json field (the md-body target).
107
+ function briefHasContentBody(declaration) {
108
+ const brief = Object.values(declaration?.sections || {}).find((s) => s && s.brief === true)
109
+ return Object.values(brief?.fields || {}).some((f) => isContentBodyField(f))
110
+ }
111
+
112
+ // Build `uuid → { collection, slug }` from the folder document's ref leaves. The
113
+ // folder is a self-nesting tree under `contents`, nesting via `$children` (the
114
+ // site-content invariant — folder.js). A leaf sits in a branch whose
115
+ // `path_segment` is the collection; the leaf's `path_segment` is the slug and its
116
+ // `entry` is the entity_ref open form `{ model, entity: <uuid> }`. Nested branches
117
+ // are walked; the collection is the NEAREST enclosing branch segment (correct for
118
+ // the default one-branch-per-collection org; a deeply nested virtual org may
119
+ // differ — see the module header).
120
+ function indexFolder(folderDoc) {
121
+ const byUuid = new Map()
122
+ const walk = (nodes, folderPath) => {
123
+ for (const node of nodes || []) {
124
+ if (node?.kind === 'branch') {
125
+ walk(node.$children, node.path_segment ?? folderPath)
126
+ } else if (node?.kind === 'ref' && node.entry) {
127
+ // `entry` is `{ model, entity: <uuid> }`; tolerate a bare uuid defensively.
128
+ const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
129
+ if (uuid) byUuid.set(uuid, { folderPath, slug: node.path_segment })
130
+ }
131
+ }
132
+ }
133
+ walk(folderDoc?.contents, null)
134
+ return byUuid
135
+ }
136
+
137
+ // Where a pulled record is written: the pool folder its MODEL names.
138
+ //
139
+ // ⛔ NOT THE QUERY'S DIRECTORY — a query has none. A record's home is decided by
140
+ // what it IS, and `entities/{schema}/` is the one place a thing of that model
141
+ // lives. That is also why the placement survives a query being renamed, added or
142
+ // deleted, none of which is a fact about the record.
143
+ //
144
+ // ⚠️ Derived by `poolDirsForSchema`, the exact inverse of the reader's
145
+ // `schemaForPoolDirs`, and deliberately not a second rule: if the two disagreed,
146
+ // a pulled record would land somewhere the next build reads as a different
147
+ // schema — silently, because both paths are well-formed.
148
+ function recordDirFor(siteRoot, model, selfOrg) {
149
+ const dirs = poolDirsForSchema(unresolveSelfScope(model, selfOrg))
150
+ return dirs ? join(siteRoot, ENTITIES_DIR, ...dirs) : null
151
+ }
152
+
153
+ /**
154
+ * Undo the self-scope resolution the producer applies before shipping.
155
+ *
156
+ * ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
157
+ * on both ends. `@/article` is a FOUNDATION-RELATIVE alias: the producer resolves
158
+ * it to `@acme/article` before it ships, because the backend resolves Models by
159
+ * name and never mints. So a record authored under `entities/article/` comes back
160
+ * as `@acme/article` and, placed literally, lands under `entities/acme/article/` —
161
+ * a different schema folder, which the next build reads as a different schema.
162
+ *
163
+ * ⚠️ It did not show before records were placed by their model: every record went
164
+ * to `collections/<collection>/` regardless, so the resolution had nowhere to leak.
165
+ *
166
+ * ⭐ The site records its own org at create (`site.yml::$org` — "whose this is"),
167
+ * which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
168
+ * genuinely is that org's, and `@/` would be a lie.
169
+ */
170
+ export function unresolveSelfScope(model, selfOrg) {
171
+ if (typeof model !== 'string' || !selfOrg) return model
172
+ const org = String(selfOrg).replace(/^@/, '').replace(/\/.*$/, '')
173
+ if (!org) return model
174
+ return model.startsWith(`@${org}/`) ? `@/${model.slice(org.length + 2)}` : model
175
+ }
176
+
177
+ // Resolve a record's (collection, slug): the folder index first (authoritative on
178
+ // a read), the record document's `$id` (`<collection>/<slug>`) as a fallback.
179
+ function locate(document, folderIndex) {
180
+ const fromFolder = document.$uuid ? folderIndex.get(document.$uuid) : null
181
+ if (fromFolder?.slug) return fromFolder
182
+ if (typeof document.$id === 'string' && document.$id.includes('/')) {
183
+ const parts = document.$id.split('/')
184
+ return { folderPath: parts.slice(0, -1).join('/'), slug: parts[parts.length - 1] }
185
+ }
186
+ return fromFolder || null
187
+ }
188
+
189
+ /** `site.yml::$org`, bare (`acme`), or null. Stored bare — see `writeSiteOrg`. */
190
+ function readSiteOrg(siteRoot) {
191
+ try {
192
+ const y = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8')) || {}
193
+ return typeof y.$org === 'string' && y.$org ? y.$org : null
194
+ } catch {
195
+ return null
196
+ }
197
+ }
198
+
199
+ // Skip undefined when copying optional fields into a projected declaration.
200
+ function setIf(obj, key, value) {
201
+ if (value !== undefined) obj[key] = value
202
+ }
203
+
204
+ // Invert one wire declaration (`queriesNested` output) back to its file-side
205
+ // shape. Returns `{ name, decl }`.
206
+ //
207
+ // - `path:` is written VERBATIM, and omitted entirely when it equals the default
208
+ // (the query's own name under the pool).
209
+ // - `url:` (remote source) and a bare `source:` object are carried as-is.
210
+ // - `schema:` is dropped when it only restates the query-name convention default,
211
+ // so a terse author file stays terse.
212
+ //
213
+ // ⛔ THE `collections/`-PREFIX STRIP AND THE site.yml ROUTING ARE BOTH GONE, and
214
+ // they went together. They existed because `collections.yml` sat INSIDE
215
+ // `collections/` and so could not express a path outside it: a path elsewhere had
216
+ // to be sent back to `site.yml` to survive the round trip. `queries.yml` is at the
217
+ // site root and its `path:` is site-root-relative, so there is one home and no
218
+ // path it cannot state. ⚠️ Leaving the strip in place would have written
219
+ // `path: items` for a source path `collections/items`, which the reader then
220
+ // resolves as `items` — a round trip that silently relocates a query's pool.
221
+ // Wire keys `declToFileShape` consumes explicitly — mapped, renamed, or folded into
222
+ // the file-side `path`/`url`. `$id`/`$uuid`/`name` are identity, not content.
223
+ const DECL_WIRE_CONSUMED = new Set([
224
+ 'name',
225
+ '$id',
226
+ '$uuid',
227
+ 'source',
228
+ 'schema',
229
+ 'sort',
230
+ 'where',
231
+ 'limit',
232
+ 'excerpt',
233
+ 'deferred',
234
+ 'detail_url',
235
+ 'queryable'
236
+ ])
237
+
238
+ // Is this wire `deferred` exactly what the schema's brief would have derived? Compared
239
+ // as an ORDER-INSENSITIVE set: the deriver walks `flatRecordFields`, and a round trip
240
+ // through YAML and the store is not obliged to preserve that order. Comparing as a list
241
+ // would classify a reordered-but-identical value as authored, and persist it.
242
+ function isDerivedDeferred(d, dataSchemas) {
243
+ if (!dataSchemas || !Array.isArray(d.deferred)) return false
244
+ const derived = deferredFromSchema(dataSchemas[d.schema])
245
+ if (!derived || derived.length !== d.deferred.length) return false
246
+ const a = new Set(derived)
247
+ return d.deferred.every((f) => a.has(f))
248
+ }
249
+
250
+ function declToFileShape(d, dataSchemas = null) {
251
+ const name = d.name || d.$id
252
+ const decl = {}
253
+
254
+ const source = d.source || {}
255
+ if (typeof source.url === 'string') {
256
+ decl.url = source.url
257
+ } else if (typeof source.path === 'string') {
258
+ // ⛔ A FILE-BASED QUERY HAS NO PATH TO WRITE BACK. `entities/{schema}/` is the
259
+ // pool and `schema:` addresses it, so a `path` arriving on the wire is either
260
+ // stale storage or something only a remote source could have meant. Dropping
261
+ // it keeps the author's file saying what the build actually reads.
262
+ decl.path = source.path
263
+ } else if (source && typeof source === 'object' && Object.keys(source).length > 0) {
264
+ decl.source = source
265
+ }
266
+
267
+ if (d.schema && d.schema !== defaultSchema(name)) decl.schema = d.schema
268
+ setIf(decl, 'sort', d.sort)
269
+ setIf(decl, 'where', d.where)
270
+ setIf(decl, 'limit', d.limit)
271
+ setIf(decl, 'excerpt', d.excerpt)
272
+ // ⛔ DO NOT WRITE A DERIVATION INTO THE AUTHOR'S FILE. `deferred:` is derived from
273
+ // the schema's brief when unstated (`collections-config.js::deriveDeferredFromSchemas`)
274
+ // — framework's own test opens with "derived from a collection's data schema, NOT
275
+ // written by hand". But the deriver mutates the declaration in place, so by the time
276
+ // it reaches the wire an emitted `deferred` is indistinguishable from an authored one.
277
+ //
278
+ // ⚠️ Measured 2026-08-29: one push + one pull turned an unstated `deferred:` into a
279
+ // hardcoded list in `collections.yml` — a DIFFERENT file, at HIGHER precedence than
280
+ // the `site.yml` the collection was declared in. The collection then stopped tracking
281
+ // its schema's brief permanently, and nothing reported it.
282
+ //
283
+ // ⭐ This is exactly what the `schema` line above already does: emit on push (the
284
+ // backend needs the effective value), drop on pull when it merely restates what would
285
+ // be derived, so a terse author file stays terse and keeps tracking its schema.
286
+ //
287
+ // ⚖️ Only an EQUAL value is dropped. An author who deliberately writes a narrower or
288
+ // wider `deferred:` than the brief implies has expressed intent, and that survives.
289
+ if (d.deferred !== undefined && !isDerivedDeferred(d, dataSchemas)) {
290
+ decl.deferred = d.deferred
291
+ }
292
+ // wire `detail_url` → file-side `detailUrl` (the key the producer reads).
293
+ if (d.detail_url !== undefined) decl.detailUrl = d.detail_url
294
+ setIf(decl, 'queryable', d.queryable)
295
+
296
+ // ⛔ PRESERVE WHAT WE DO NOT MODEL — the pull half of the same rule the emitter
297
+ // follows (`site.js::queriesNested`). A wire field this function has not been
298
+ // taught is dropped here and then absent on the next push, where the backend's
299
+ // wholesale `data` replace destroys it. Two allowlists facing each other make the
300
+ // round trip lossy in BOTH directions with nothing reporting it.
301
+ //
302
+ // An unknown WIRE key is safe to keep verbatim: unlike the push direction there is
303
+ // no framework-local vocabulary to filter out, because everything here came off the
304
+ // backend's Model.
305
+ for (const [key, value] of Object.entries(d)) {
306
+ if (value === undefined || DECL_WIRE_CONSUMED.has(key)) continue
307
+ decl[key] = value
308
+ }
309
+
310
+ return { name, decl }
311
+ }
312
+
313
+ /**
314
+ * Project the QUERY declarations carried in a site-content document
315
+ * (`document.queries`, the inverse of site.js `queriesNested`) back to
316
+ * `queries.yml` — the one home. Untouched queries are preserved via the
317
+ * shallow-merge writer. The record FILES are written elsewhere
318
+ * (recordsToProject); this is only the declaration config.
319
+ *
320
+ * Idempotent and non-destructive: with no declarations it writes nothing (so a
321
+ * pull that doesn't carry collections never clobbers a hand-authored file).
322
+ *
323
+ * @param {object} params
324
+ * @param {object} params.document - a site-content `$`-document (`{ queries }`)
325
+ * @param {string} params.siteRoot
326
+ * @returns {{ collections?: 'updated'|'unchanged' }}
327
+ */
328
+ export function declarationsToQueriesYml({ document, siteRoot }) {
329
+ const decls = Array.isArray(document?.queries) ? document.queries : []
330
+ const report = {}
331
+ if (decls.length === 0) return report
332
+
333
+ // The foundation's data schemas, loaded ONCE for the whole projection — they are what
334
+ // lets `declToFileShape` tell a derived `deferred:` from an authored one. Absent (no
335
+ // foundation on disk, unbuilt, unresolvable) the inverter simply never fires and every
336
+ // `deferred` is treated as authored: the pre-2026-08-29 behaviour, which is the safe
337
+ // direction to fail — persisting a value that did not need persisting loses nothing,
338
+ // where dropping an AUTHORED one would.
339
+ let siteYml = null
340
+ try {
341
+ siteYml = yaml.load(readFileSync(join(siteRoot, 'site.yml'), 'utf8')) || null
342
+ } catch {
343
+ siteYml = null
344
+ }
345
+ const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
346
+
347
+ const queries = {}
348
+ for (const d of decls) {
349
+ const { name, decl } = declToFileShape(d, dataSchemas)
350
+ if (!name) continue
351
+ queries[name] = decl
352
+ }
353
+
354
+ if (Object.keys(queries).length > 0) {
355
+ report.queries = writeQueriesConfig(siteRoot, queries)
356
+ }
357
+ return report
358
+ }
359
+
360
+ /**
361
+ * Project a pulled `@uniweb/folder` document back to `records.yml`.
362
+ *
363
+ * ⭐ THE FOLDER IS THE ONE THING THAT ROUND-TRIPS TRIVIALLY, and that is by design
364
+ * rather than luck: `records.yml` holds concrete refs on both sides, so there is
365
+ * nothing to invert. The old shape put QUERY MACROS in the folder — a virtual
366
+ * `folders:` tree naming collections — and inverting a macro is not possible in
367
+ * general, which is why rewriting it stayed deferred for as long as it existed.
368
+ * Taking queries out of the folder is what dissolved that.
369
+ *
370
+ * ⛔ AN EMPTY RESULT IS NOT WRITTEN. An empty `records.yml` means "the folder holds
371
+ * nothing" and REMOVES on the next push, so a pull that carried no folder — or one
372
+ * whose leaves could not be placed — must leave the file alone rather than author
373
+ * the destructive state on the author's behalf.
374
+ *
375
+ * @param {object} params
376
+ * @param {object} params.folderDoc - the stored `@uniweb/folder` document
377
+ * @param {string} params.siteRoot
378
+ * @param {Map<string,string>} params.poolPathByUuid - record `$uuid` → the path
379
+ * under `entities/` of the file just written for it. Supplied by
380
+ * `recordsToProject`, which is the only thing that knows the extension
381
+ * each record landed with.
382
+ * @returns {{ status: 'updated'|'unchanged'|'skipped', entries: Array, warnings: string[] }}
383
+ */
384
+ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
385
+ const warnings = []
386
+
387
+ const walk = (nodes) => {
388
+ const out = []
389
+ for (const node of nodes || []) {
390
+ if (!node || typeof node !== 'object') continue
391
+ if (node.kind === 'branch') {
392
+ const entry = { folder: node.path_segment }
393
+ // Only a BRANCH takes a label. A record carries its own title; the folder
394
+ // does not caption its rows.
395
+ if (node.name !== undefined) entry.label = node.name
396
+ entry.records = walk(node.$children)
397
+ out.push(entry)
398
+ continue
399
+ }
400
+ const uuid = node.entry?.entity ?? node.entry
401
+ const rel = typeof uuid === 'string' ? poolPathByUuid.get(uuid) : null
402
+ if (!rel) {
403
+ // ⚠️ Reported, never dropped in silence. A leaf whose record did not land
404
+ // means the folder and the pool disagree, and writing the file without it
405
+ // would quietly unpublish that record on the next push.
406
+ warnings.push(
407
+ `records.yml: a folder leaf ("${node.path_segment ?? '?'}") references a record that ` +
408
+ `was not written locally — the file was left unchanged rather than dropping it.`
409
+ )
410
+ return null
411
+ }
412
+ out.push(rel)
413
+ }
414
+ return out
415
+ }
416
+
417
+ const entries = walk(folderDoc?.contents)
418
+ if (entries === null) return { status: 'skipped', entries: [], warnings }
419
+ if (entries.length === 0) return { status: 'skipped', entries: [], warnings }
420
+ return { status: writeRecordsConfig(siteRoot, entries), entries, warnings }
421
+ }
422
+
423
+ /**
424
+ * Project a pulled folder + its record entities to `entities/**` files.
425
+ *
426
+ * @param {object} params
427
+ * @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
428
+ * @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $model, <brief> }`
429
+ * @param {string} params.siteRoot
430
+ * @param {object} params.opts
431
+ * @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
432
+ * - resolve a Model's data-schema declaration by name (`$model`).
433
+ * @param {string} [params.opts.org] - the site's own org, so a `@org/x` model the
434
+ * producer resolved from `@/x` is placed back where the author wrote it.
435
+ * Defaults to `site.yml::$org`.
436
+ * @param {string} [params.opts.sourceLocale]
437
+ * @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
438
+ */
439
+ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
440
+ const { resolveDeclaration, sourceLocale = 'en' } = opts
441
+ // The site's own org, so a `@org/x` model the producer resolved from `@/x` is
442
+ // placed back where the author wrote it. Read from `site.yml::$org` unless the
443
+ // caller already has it.
444
+ const selfOrg = opts.org ?? readSiteOrg(siteRoot)
445
+ if (typeof resolveDeclaration !== 'function') {
446
+ throw new Error('uwx/records-project: opts.resolveDeclaration(modelName) is required')
447
+ }
448
+
449
+ const folderIndex = indexFolder(folderDoc)
450
+ // Captures target-locale translations of localized record fields: SCALARs →
451
+ // locales/records/{locale}.json (structural maps too), and a prosemirror
452
+ // BODY's free-form per-locale override → locales/freeform/{locale}/collections/.
453
+ const collector = createTranslationCollector(sourceLocale)
454
+ const updated = []
455
+ const placed = []
456
+ const unchanged = []
457
+ const skipped = []
458
+ const warnings = []
459
+ // uuid → the path under `entities/` the record landed at. Only this loop knows
460
+ // the extension each one got, so `records.yml` is written from it rather than
461
+ // re-derived (a second rule could pick a different extension and the folder
462
+ // would name a file that is not there).
463
+ const poolPathByUuid = new Map()
464
+
465
+ for (const document of recordDocs) {
466
+ const where = locate(document, folderIndex)
467
+ if (!where?.slug) {
468
+ skipped.push({ uuid: document.$uuid, reason: 'no slug (not in the folder, no $id)' })
469
+ continue
470
+ }
471
+ const declaration = document.$model ? resolveDeclaration(document.$model) : null
472
+ if (!declaration) {
473
+ skipped.push({ uuid: document.$uuid, slug: where.slug, reason: `unresolved model ${document.$model || '(none)'}` })
474
+ continue
475
+ }
476
+
477
+ const poolDir = recordDirFor(siteRoot, document.$model, selfOrg)
478
+ if (!poolDir) {
479
+ skipped.push({
480
+ uuid: document.$uuid,
481
+ slug: where.slug,
482
+ reason: `model ${document.$model} names no pool folder (expected @/name or @org/name)`,
483
+ })
484
+ continue
485
+ }
486
+ const existing = document.$uuid ? findRecordFileByUuid(poolDir, document.$uuid) : null
487
+
488
+ let filePath
489
+ let format
490
+ let isNew
491
+ if (existing) {
492
+ filePath = existing.path
493
+ format = existing.format
494
+ isNew = false
495
+ } else {
496
+ format = defaultFormat(poolDir, declaration)
497
+ filePath = join(poolDir, where.slug + EXT_FOR_FORMAT[format])
498
+ isNew = true
499
+ }
500
+
501
+ // The free-form home for this record's content body (locale-independent); a
502
+ // target-locale full-doc body is written under locales/freeform/{locale}/here.
503
+ const freeformRelPath = buildFreeformRecordPath(document.$model, where.slug)
504
+
505
+ let status
506
+ try {
507
+ status = writeRecordFile({ filePath, document, declaration, format, sourceLocale, collector, freeformRelPath })
508
+ } catch (err) {
509
+ warnings.push(`${where.slug}: ${err.message}`)
510
+ continue
511
+ }
512
+ if (status === 'unchanged') unchanged.push(filePath)
513
+ else if (isNew) placed.push(filePath)
514
+ else updated.push(filePath)
515
+ if (document.$uuid) {
516
+ poolPathByUuid.set(document.$uuid, relative(join(siteRoot, ENTITIES_DIR), filePath).split(sep).join('/'))
517
+ }
518
+ }
519
+
520
+ // ⭐ THE FOLDER ITSELF, written back as `records.yml`. Steps that only touched the
521
+ // READ path would leave every pull authoring the old shape — the site would build
522
+ // from the new layout and be projected back into the one it replaced.
523
+ //
524
+ // The folder ENTITY still carries no `$uuid` we persist: the backend owns the
525
+ // site's folder, keyed by the site-content uuid.
526
+ const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid })
527
+ warnings.push(...records.warnings)
528
+
529
+ // Flush localized record-field translations to locales/records/{locale}.json,
530
+ // and any prosemirror free-form body overrides to locales/freeform/{locale}/.
531
+ const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'records')
532
+ const freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
533
+
534
+ return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status }
535
+ }