@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
|
@@ -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
|