@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
@@ -1,335 +0,0 @@
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` is the collection name (folder.js
11
- // `defaultContents`). The
12
- // folder is the authoritative organization on a read (the record document's
13
- // own `$id` envelope is not guaranteed to be echoed back), with the record
14
- // document's `$id` (`<collection>/<slug>`) used as a fallback when present.
15
- // - the collection's directory is resolved from the collections config
16
- // (`collections.yml`/`site.yml` `path:`), defaulting to `collections/<name>`.
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, extname, basename } from 'node:path'
37
- import yaml from 'js-yaml'
38
- import { parseFrontmatter } from './collection-source.js'
39
- import { writeRecordFile, writeCollectionsConfig, writeSiteConfig } from './project-writer.js'
40
- import { defaultSchema } from './collections-config.js'
41
- import { isContentBodyField } from './data-schema.js'
42
- import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
43
- import { buildFreeformCollectionPath } from '../i18n/freeform.js'
44
-
45
- // Single-record source extensions we scan + place (BibTeX is multi-record → out).
46
- const EXT_FOR_FORMAT = { md: '.md', yaml: '.yml', json: '.json' }
47
-
48
- function formatForExt(ext) {
49
- if (ext === '.md') return 'md'
50
- if (ext === '.yml' || ext === '.yaml') return 'yaml'
51
- if (ext === '.json') return 'json'
52
- return null
53
- }
54
-
55
- // Read the `$uuid` declared in a single-record source file, or null (array-form,
56
- // unreadable, or no `$uuid`). Used to find an existing local file for a record.
57
- function readFileUuid(filePath, format) {
58
- let raw
59
- try {
60
- raw = readFileSync(filePath, 'utf8')
61
- } catch {
62
- return null
63
- }
64
- try {
65
- if (format === 'md') return parseFrontmatter(raw).frontmatter?.$uuid ?? null
66
- const parsed = format === 'json' ? JSON.parse(raw) : yaml.load(raw)
67
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
68
- return parsed.$uuid ?? null
69
- } catch {
70
- return null
71
- }
72
- }
73
-
74
- /**
75
- * Find the single-record file in `collectionDir` whose `$uuid` matches, or null.
76
- * @returns {{ path: string, format: 'md'|'yaml'|'json' }|null}
77
- */
78
- export function findRecordFileByUuid(collectionDir, uuid) {
79
- if (!uuid || !existsSync(collectionDir)) return null
80
- for (const entry of readdirSync(collectionDir)) {
81
- if (entry.startsWith('_')) continue
82
- const format = formatForExt(extname(entry).toLowerCase())
83
- if (!format) continue
84
- const path = join(collectionDir, entry)
85
- if (readFileUuid(path, format) === uuid) return { path, format }
86
- }
87
- return null
88
- }
89
-
90
- // The format to give a NEW record file in a collection: match the collection's
91
- // existing single-record files, else markdown when the Model's brief carries a
92
- // content body field (so the body has a home), else YAML.
93
- function defaultFormat(collectionDir, declaration) {
94
- if (existsSync(collectionDir)) {
95
- for (const entry of readdirSync(collectionDir)) {
96
- if (entry.startsWith('_')) continue
97
- const format = formatForExt(extname(entry).toLowerCase())
98
- if (format) return format
99
- }
100
- }
101
- return briefHasContentBody(declaration) ? 'md' : 'yaml'
102
- }
103
-
104
- // Whether the declaration's brief section declares a content body field — a markup
105
- // `text` field or a `format: prosemirror` json field (the md-body target).
106
- function briefHasContentBody(declaration) {
107
- const brief = Object.values(declaration?.sections || {}).find((s) => s && s.brief === true)
108
- return Object.values(brief?.fields || {}).some((f) => isContentBodyField(f))
109
- }
110
-
111
- // Build `uuid → { collection, slug }` from the folder document's ref leaves. The
112
- // folder is a self-nesting tree under `contents`, nesting via `$children` (the
113
- // site-content invariant — folder.js). A leaf sits in a branch whose
114
- // `path_segment` is the collection; the leaf's `path_segment` is the slug and its
115
- // `entry` is the entity_ref open form `{ model, entity: <uuid> }`. Nested branches
116
- // are walked; the collection is the NEAREST enclosing branch segment (correct for
117
- // the default one-branch-per-collection org; a deeply nested virtual org may
118
- // differ — see the module header).
119
- function indexFolder(folderDoc) {
120
- const byUuid = new Map()
121
- const walk = (nodes, collection) => {
122
- for (const node of nodes || []) {
123
- if (node?.kind === 'branch') {
124
- walk(node.$children, node.path_segment ?? collection)
125
- } else if (node?.kind === 'ref' && node.entry) {
126
- // `entry` is `{ model, entity: <uuid> }`; tolerate a bare uuid defensively.
127
- const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
128
- if (uuid) byUuid.set(uuid, { collection, slug: node.path_segment })
129
- }
130
- }
131
- }
132
- walk(folderDoc?.contents, null)
133
- return byUuid
134
- }
135
-
136
- // Resolve a collection's directory from the collections config `path:` (already
137
- // site-root-relative), defaulting to `collections/<name>`.
138
- function collectionDirFor(siteRoot, collection, collectionsConfig) {
139
- const declPath = collectionsConfig?.declarations?.[collection]?.path
140
- return declPath ? resolve(siteRoot, declPath) : join(siteRoot, 'collections', collection)
141
- }
142
-
143
- // Resolve a record's (collection, slug): the folder index first (authoritative on
144
- // a read), the record document's `$id` (`<collection>/<slug>`) as a fallback.
145
- function locate(document, folderIndex) {
146
- const fromFolder = document.$uuid ? folderIndex.get(document.$uuid) : null
147
- if (fromFolder?.collection && fromFolder.slug) return fromFolder
148
- if (typeof document.$id === 'string' && document.$id.includes('/')) {
149
- const [collection, ...rest] = document.$id.split('/')
150
- return { collection, slug: rest.join('/') }
151
- }
152
- return fromFolder || null
153
- }
154
-
155
- const COLLECTIONS_PREFIX = 'collections/'
156
-
157
- // Skip undefined when copying optional fields into a projected declaration.
158
- function setIf(obj, key, value) {
159
- if (value !== undefined) obj[key] = value
160
- }
161
-
162
- // Invert one wire declaration (`collectionsNested` output) back to its file-side
163
- // shape, and decide its home. Returns `{ target: 'col'|'site', name, decl }`.
164
- //
165
- // - A `path:` under `collections/` is the canonical collections.yml home; the
166
- // prefix is stripped (collections.yml `path:` is relative to `collections/`)
167
- // and omitted entirely when it equals the default (the collection name).
168
- // - A `path:` NOT under `collections/` is a legacy `site.yml::collections` local
169
- // path (kept verbatim there — collections.yml can't express it), so it routes
170
- // back to site.yml to round-trip faithfully.
171
- // - `url:` (remote source) and a bare `source:` object go to collections.yml.
172
- // - `schema:` is dropped when it only restates the subfolder-name convention
173
- // default, so a terse author file stays terse.
174
- function declToFileShape(d) {
175
- const name = d.name || d.$id
176
- const decl = {}
177
- let target = 'col'
178
-
179
- const source = d.source || {}
180
- if (typeof source.url === 'string') {
181
- decl.url = source.url
182
- } else if (typeof source.path === 'string') {
183
- if (source.path.startsWith(COLLECTIONS_PREFIX)) {
184
- const rel = source.path.slice(COLLECTIONS_PREFIX.length)
185
- if (rel !== name) decl.path = rel
186
- } else {
187
- target = 'site'
188
- decl.path = source.path
189
- }
190
- } else if (source && typeof source === 'object' && Object.keys(source).length > 0) {
191
- decl.source = source
192
- }
193
-
194
- if (d.schema && d.schema !== defaultSchema(name)) decl.schema = d.schema
195
- setIf(decl, 'sort', d.sort)
196
- setIf(decl, 'where', d.where)
197
- setIf(decl, 'limit', d.limit)
198
- setIf(decl, 'excerpt', d.excerpt)
199
- setIf(decl, 'deferred', d.deferred)
200
- // wire `detail_url` → file-side `detailUrl` (the key the producer reads).
201
- if (d.detail_url !== undefined) decl.detailUrl = d.detail_url
202
- setIf(decl, 'queryable', d.queryable)
203
-
204
- return { target, name, decl }
205
- }
206
-
207
- /**
208
- * Project the collection DECLARATIONS carried in a site-content document
209
- * (`document.collections`, the inverse of site.js `collectionsNested`) back to
210
- * their config home — `collections/collections.yml::collections` for the canonical
211
- * file-based + remote sources, `site.yml::collections` for legacy local paths
212
- * outside `collections/`. Sibling keys (`$uuid`, `sync`, `folders`, untouched
213
- * collections) are preserved via the shallow-merge writers. The record FILES and
214
- * the folder `$uuid` are written elsewhere (collectionsToProject); this is only
215
- * the declaration config.
216
- *
217
- * Idempotent and non-destructive: with no declarations it writes nothing (so a
218
- * pull that doesn't carry collections never clobbers a hand-authored file).
219
- *
220
- * @param {object} params
221
- * @param {object} params.document - a site-content `$`-document (`{ collections }`)
222
- * @param {string} params.siteRoot
223
- * @returns {{ collections?: 'updated'|'unchanged', site?: 'updated'|'unchanged' }}
224
- */
225
- export function declarationsToCollectionsYml({ document, siteRoot }) {
226
- const decls = Array.isArray(document?.collections) ? document.collections : []
227
- const report = {}
228
- if (decls.length === 0) return report
229
-
230
- const colCollections = {}
231
- const siteCollections = {}
232
- for (const d of decls) {
233
- const { target, name, decl } = declToFileShape(d)
234
- if (!name) continue
235
- if (target === 'site') siteCollections[name] = decl
236
- else colCollections[name] = decl
237
- }
238
-
239
- if (Object.keys(colCollections).length > 0) {
240
- report.collections = writeCollectionsConfig(siteRoot, { collections: colCollections })
241
- }
242
- if (Object.keys(siteCollections).length > 0) {
243
- report.site = writeSiteConfig(siteRoot, { collections: siteCollections })
244
- }
245
- return report
246
- }
247
-
248
- /**
249
- * Project a pulled folder + its record entities to `collections/**` files.
250
- *
251
- * @param {object} params
252
- * @param {object} params.folderDoc - the `@uniweb/folder` document `{ contents }` (no `$uuid`)
253
- * @param {object[]} params.recordDocs - record `$`-documents `{ $uuid?, $id?, $model, <brief> }`
254
- * @param {string} params.siteRoot
255
- * @param {object} params.opts
256
- * @param {(modelName: string) => object|null|undefined} params.opts.resolveDeclaration
257
- * - resolve a Model's data-schema declaration by name (`$model`).
258
- * @param {object} [params.opts.collectionsConfig] - from resolveCollectionsConfig
259
- * (for `path:` overrides); optional — defaults to `collections/<name>`.
260
- * @param {string} [params.opts.sourceLocale]
261
- * @returns {{ updated: string[], placed: string[], unchanged: string[], skipped: object[], warnings: string[], locales: object }}
262
- */
263
- export function collectionsToProject({ folderDoc, recordDocs = [], siteRoot, opts = {} }) {
264
- const { resolveDeclaration, collectionsConfig, sourceLocale = 'en' } = opts
265
- if (typeof resolveDeclaration !== 'function') {
266
- throw new Error('uwx/collections-project: opts.resolveDeclaration(modelName) is required')
267
- }
268
-
269
- const folderIndex = indexFolder(folderDoc)
270
- // Captures target-locale translations of localized record fields: SCALARs →
271
- // locales/collections/{locale}.json (structural maps too), and a prosemirror
272
- // BODY's free-form per-locale override → locales/freeform/{locale}/collections/.
273
- const collector = createTranslationCollector(sourceLocale)
274
- const updated = []
275
- const placed = []
276
- const unchanged = []
277
- const skipped = []
278
- const warnings = []
279
-
280
- for (const document of recordDocs) {
281
- const where = locate(document, folderIndex)
282
- if (!where?.collection || !where?.slug) {
283
- skipped.push({ uuid: document.$uuid, reason: 'no collection/slug (not in folder, no $id)' })
284
- continue
285
- }
286
- const declaration = document.$model ? resolveDeclaration(document.$model) : null
287
- if (!declaration) {
288
- skipped.push({ uuid: document.$uuid, slug: where.slug, reason: `unresolved model ${document.$model || '(none)'}` })
289
- continue
290
- }
291
-
292
- const collectionDir = collectionDirFor(siteRoot, where.collection, collectionsConfig)
293
- const existing = document.$uuid ? findRecordFileByUuid(collectionDir, document.$uuid) : null
294
-
295
- let filePath
296
- let format
297
- let isNew
298
- if (existing) {
299
- filePath = existing.path
300
- format = existing.format
301
- isNew = false
302
- } else {
303
- format = defaultFormat(collectionDir, declaration)
304
- filePath = join(collectionDir, where.slug + EXT_FOR_FORMAT[format])
305
- isNew = true
306
- }
307
-
308
- // The free-form home for this record's content body (locale-independent); a
309
- // target-locale full-doc body is written under locales/freeform/{locale}/here.
310
- const freeformRelPath = buildFreeformCollectionPath(where.collection, where.slug)
311
-
312
- let status
313
- try {
314
- status = writeRecordFile({ filePath, document, declaration, format, sourceLocale, collector, freeformRelPath })
315
- } catch (err) {
316
- warnings.push(`${where.collection}/${where.slug}: ${err.message}`)
317
- continue
318
- }
319
- if (status === 'unchanged') unchanged.push(filePath)
320
- else if (isNew) placed.push(filePath)
321
- else updated.push(filePath)
322
- }
323
-
324
- // The folder carries no `$uuid` we persist — the backend owns the site's folder,
325
- // keyed by the site-content uuid (the folder pull lane is keyed by `site.yml::$uuid`).
326
- // The virtual `folders:` org + declarations for newly-introduced collections are a
327
- // later, comment-sensitive rewrite.
328
-
329
- // Flush localized record-field translations to locales/collections/{locale}.json,
330
- // and any prosemirror free-form body overrides to locales/freeform/{locale}/.
331
- const locales = writeLocaleTranslations(siteRoot, collector.byLocale, 'collections')
332
- const freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
333
-
334
- return { updated, placed, unchanged, skipped, warnings, locales, freeform }
335
- }
File without changes