@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.
- package/package.json +5 -5
- 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 +35 -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 +92 -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 +30 -5
- package/src/uwx/site.js +295 -27
- 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
|
@@ -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
|
+
}
|