@uniweb/build 0.29.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/content/index.js +6 -6
- package/src/dev-backend.js +31 -31
- package/src/i18n/freeform.js +44 -24
- package/src/i18n/index.js +22 -22
- package/src/i18n/{collections.js → records.js} +114 -51
- package/src/i18n/sync.js +9 -8
- package/src/site/build-site-data.js +9 -12
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +25 -40
- package/src/site/data-fetcher.js +23 -10
- package/src/site/entity-pool.js +211 -0
- package/src/site/fetch-shapes.js +71 -0
- package/src/site/foundation-ref.js +1 -1
- package/src/site/index.js +4 -4
- package/src/site/plugin.js +58 -63
- package/src/site/queries-config.js +324 -0
- package/src/site/{collection-processor.js → query-processor.js} +180 -95
- package/src/site/records-config.js +299 -0
- package/src/site/schemaless-data.js +2 -2
- package/src/utils/numeric-prefix.js +63 -0
- package/src/uwx/backfill.js +5 -5
- package/src/uwx/data-schema.js +2 -2
- package/src/uwx/entity-source.js +122 -0
- package/src/uwx/folder.js +85 -77
- package/src/uwx/index.js +33 -12
- 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 +24 -1
- package/src/uwx/site-project.js +9 -6
- package/src/uwx/site.js +179 -23
- package/src/uwx/sync-package.js +37 -16
- 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
package/src/uwx/folder.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
|
-
// Build the one `@uniweb/folder` entity that organizes a site's
|
|
1
|
+
// Build the one `@uniweb/folder` entity that organizes a site's records.
|
|
2
2
|
//
|
|
3
|
-
// A site sync carries the site-content entity, the
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// A site sync carries the site-content entity, the record entities, and — when the
|
|
4
|
+
// site has records — ONE `@uniweb/folder` entity describing how they are
|
|
5
|
+
// organized. `@uniweb/folder` is a normal section-keyed entity (the "structured
|
|
6
|
+
// content all the way down" invariant): its document is `{ info?, contents }`.
|
|
7
7
|
// - `contents` is the self-nesting tree (an array), nesting via `$children` — the
|
|
8
8
|
// same mechanism site-content pages/sections use. Each node holds REFERENCES,
|
|
9
9
|
// never content:
|
|
10
10
|
// - a LEAF references one record entity: `{ kind: 'ref', path_segment, ... }`
|
|
11
11
|
// with `entry: <uuid>` once the record was minted (back-filled into its file),
|
|
12
|
-
// or `$ref: "<
|
|
12
|
+
// or `$ref: "<id>"` while brand-new (resolved within this payload).
|
|
13
13
|
// - a BRANCH is a sub-folder: `{ kind: 'branch', path_segment, name?, $children }`.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
15
|
+
// ⭐ THE ORGANIZATION IS AUTHORED, IN `records.yml`, AND IT IS THE ONLY SOURCE.
|
|
16
|
+
// It used to be DERIVED — one branch per collection, mirroring the `collections/`
|
|
17
|
+
// subfolders, with an optional `collections.yml::folders` virtual tree layered
|
|
18
|
+
// over it. Both are gone, and the difference is the point: a folder is a thing
|
|
19
|
+
// the author states, not a shadow of a directory layout. `records.yml` also
|
|
20
|
+
// decides WHAT syncs at all, since listing an entity is what makes it a record.
|
|
21
|
+
//
|
|
22
|
+
// ⛔ SO THERE IS NO DEFAULT. A site with no `records.yml` has no folder and syncs
|
|
23
|
+
// no records — which is the model's `missing ⇒ inert` ruling, not an empty folder.
|
|
24
|
+
// Do not reintroduce a fallback grouping: it would resurrect exactly the
|
|
25
|
+
// three-jobs-in-one-directory conflation the layout was changed to remove.
|
|
18
26
|
//
|
|
19
27
|
// The folder carries NO `$uuid` of its own: the backend owns the site's
|
|
20
28
|
// `@uniweb/folder` and resolves it from the site-content uuid (the folder sync lane
|
|
@@ -23,11 +31,12 @@
|
|
|
23
31
|
export const FOLDER_MODEL_NAME = '@uniweb/folder'
|
|
24
32
|
export const FOLDER_ENTITY_KEY = '@folder'
|
|
25
33
|
|
|
26
|
-
//
|
|
27
|
-
// reference any Model), so the ref uses the
|
|
28
|
-
// — not a bare uuid (a bare uuid is only
|
|
29
|
-
// Known uuid → `entry: { model, entity
|
|
30
|
-
// (resolved within this payload to the minted
|
|
34
|
+
// Point one authored leaf at the record entity it names. The folder's `contents`
|
|
35
|
+
// field is polymorphic (it can reference any Model), so the ref uses the
|
|
36
|
+
// entity_ref OPEN form `{ model, entity }` — not a bare uuid (a bare uuid is only
|
|
37
|
+
// valid when the field pins a single model). Known uuid → `entry: { model, entity
|
|
38
|
+
// }`; brand-new → `$ref` handle (resolved within this payload to the minted
|
|
39
|
+
// entity).
|
|
31
40
|
//
|
|
32
41
|
// TODO: the sync lane is uuid-keyed, so `model` should be the resolved Model UUID;
|
|
33
42
|
// it currently carries the Model NAME (e.g. `@std/article`). Wire the name→uuid
|
|
@@ -35,68 +44,42 @@ export const FOLDER_ENTITY_KEY = '@folder'
|
|
|
35
44
|
function refLeaf(entity) {
|
|
36
45
|
const leaf = { kind: 'ref', path_segment: entity.slug }
|
|
37
46
|
if (entity.uuid) leaf.entry = { model: entity.model, entity: entity.uuid }
|
|
38
|
-
else leaf.$ref = entity.id // the
|
|
47
|
+
else leaf.$ref = entity.id // the payload-local handle
|
|
39
48
|
return leaf
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
function
|
|
55
|
-
const
|
|
56
|
-
for (const
|
|
57
|
-
|
|
58
|
-
kind: 'branch',
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
return contents
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// Virtual org from `collections.yml::folders`. Each node is either a collection
|
|
67
|
-
// NAME (string — expands to that collection's record leaves under a branch named
|
|
68
|
-
// after it) or a `{ segment, label?, entries: [...] }` branch (recursively).
|
|
69
|
-
function virtualContents(folders, groups) {
|
|
70
|
-
const buildNode = (node) => {
|
|
71
|
-
if (typeof node === 'string') {
|
|
72
|
-
const records = groups.get(node) || []
|
|
73
|
-
return {
|
|
74
|
-
kind: 'branch',
|
|
75
|
-
path_segment: node,
|
|
76
|
-
$children: records.map(refLeaf),
|
|
77
|
-
}
|
|
51
|
+
/**
|
|
52
|
+
* Turn the resolved `records.yml` tree into folder `contents`.
|
|
53
|
+
*
|
|
54
|
+
* ⛔ A LEAF WHOSE ENTITY IS MISSING IS DROPPED AND REPORTED, never emitted empty.
|
|
55
|
+
* A `ref` with neither `entry` nor `$ref` is a placement pointing at nothing —
|
|
56
|
+
* the backend cannot resolve it, and the failure would surface there rather than
|
|
57
|
+
* here, as somebody else's error.
|
|
58
|
+
*
|
|
59
|
+
* @param {Array} nodes - from `site/records-config.js::resolveFolder`
|
|
60
|
+
* @param {Map<string, object>} byEntityId - record entities, keyed by pool id
|
|
61
|
+
* @param {string[]} missing - collects ids that resolved to no entity
|
|
62
|
+
*/
|
|
63
|
+
function contentsFromNodes(nodes, byEntityId, missing) {
|
|
64
|
+
const out = []
|
|
65
|
+
for (const node of nodes || []) {
|
|
66
|
+
if (node.kind === 'branch') {
|
|
67
|
+
const branch = { kind: 'branch', path_segment: node.path_segment }
|
|
68
|
+
if (node.name !== undefined) branch.name = node.name
|
|
69
|
+
branch.$children = contentsFromNodes(node.$children, byEntityId, missing)
|
|
70
|
+
out.push(branch)
|
|
71
|
+
continue
|
|
78
72
|
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const children = Array.isArray(node.entries) ? node.entries : []
|
|
84
|
-
branch.$children = children.flatMap((child) => {
|
|
85
|
-
// A bare collection name inside `entries:` expands to its leaves directly
|
|
86
|
-
// (so the records sit in THIS branch, not a nested one).
|
|
87
|
-
if (typeof child === 'string' && groups.has(child)) {
|
|
88
|
-
return (groups.get(child) || []).map(refLeaf)
|
|
89
|
-
}
|
|
90
|
-
return [buildNode(child)]
|
|
91
|
-
})
|
|
92
|
-
return branch
|
|
73
|
+
const entity = byEntityId.get(node.$entityId)
|
|
74
|
+
if (!entity) {
|
|
75
|
+
missing.push(node.$entityId)
|
|
76
|
+
continue
|
|
93
77
|
}
|
|
94
|
-
|
|
78
|
+
out.push(refLeaf(entity))
|
|
95
79
|
}
|
|
96
|
-
return
|
|
80
|
+
return out
|
|
97
81
|
}
|
|
98
82
|
|
|
99
|
-
|
|
100
83
|
/**
|
|
101
84
|
* Walk a folder document's `contents` tree, visiting every item with the
|
|
102
85
|
* slash-joined `path_segment` chain that addresses it.
|
|
@@ -167,24 +150,48 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
|
|
|
167
150
|
}
|
|
168
151
|
|
|
169
152
|
/**
|
|
170
|
-
* Build the `@uniweb/folder` entity descriptor, or null when
|
|
153
|
+
* Build the `@uniweb/folder` entity descriptor, or null when the folder is empty.
|
|
171
154
|
*
|
|
172
155
|
* Carries no `$uuid`: the backend owns the site's folder (resolved from the
|
|
173
156
|
* site-content uuid), so the framework never mints, holds, or sends a folder uuid.
|
|
174
157
|
*
|
|
175
158
|
* @param {object} params
|
|
176
|
-
* @param {object[]} params.recordEntities - the
|
|
177
|
-
*
|
|
178
|
-
* @param {Array
|
|
159
|
+
* @param {object[]} params.recordEntities - the record entities (full set, BEFORE
|
|
160
|
+
* send-only-changed filtering), each `{ id, uuid, slug, model }`
|
|
161
|
+
* @param {Array} params.folderNodes - the resolved `records.yml` tree
|
|
162
|
+
* @param {boolean} [params.declared] - whether `records.yml` EXISTS. See below.
|
|
179
163
|
* @param {Record<string,string>} [params.itemUuids] - path → `$uuid`, harvested
|
|
180
164
|
* from the folder document a previous push returned. Absent on a first
|
|
181
165
|
* push, where every item is genuinely new.
|
|
182
|
-
* @returns {{ id, uuid, model, file, document,
|
|
166
|
+
* @returns {{ id, uuid, model, file, document, warnings }|null}
|
|
183
167
|
*/
|
|
184
|
-
export function buildFolderEntity({ recordEntities,
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
168
|
+
export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null }) {
|
|
169
|
+
// ⛔ `missing` AND `empty` ARE DIFFERENT, AND THE ASYMMETRY IS DELIBERATE.
|
|
170
|
+
//
|
|
171
|
+
// no records.yml → null. INERT: nothing is sent, and the server's
|
|
172
|
+
// folder is left exactly as it is.
|
|
173
|
+
// records.yml, empty → a folder with `contents: []`. DESTRUCTIVE: it says
|
|
174
|
+
// the folder holds nothing, so the backend removes
|
|
175
|
+
// what is there.
|
|
176
|
+
//
|
|
177
|
+
// ⭐ The safe state is the ABSENCE of a file and the destructive act requires
|
|
178
|
+
// affirmatively CREATING one — so a live folder cannot be wiped by deleting
|
|
179
|
+
// something. ⛔ Do not "simplify" these into one behaviour to avoid the
|
|
180
|
+
// placeholder hazard (an empty file created meaning to fill it in): that would
|
|
181
|
+
// delete a capability to avoid writing a prompt. The CLI asks, with a count.
|
|
182
|
+
const empty = !Array.isArray(folderNodes) || folderNodes.length === 0
|
|
183
|
+
if (empty && !declared) return null
|
|
184
|
+
|
|
185
|
+
const byEntityId = new Map()
|
|
186
|
+
for (const e of recordEntities || []) byEntityId.set(e.id, e)
|
|
187
|
+
|
|
188
|
+
const missing = []
|
|
189
|
+
const contents = contentsFromNodes(folderNodes, byEntityId, missing)
|
|
190
|
+
const warnings = missing.map(
|
|
191
|
+
(id) =>
|
|
192
|
+
`folder: "${id}" is placed in records.yml but produced no record entity — ` +
|
|
193
|
+
`the placement was dropped rather than sent pointing at nothing.`
|
|
194
|
+
)
|
|
188
195
|
|
|
189
196
|
const document = {
|
|
190
197
|
$id: FOLDER_ENTITY_KEY,
|
|
@@ -202,5 +209,6 @@ export function buildFolderEntity({ recordEntities, folders = null, itemUuids =
|
|
|
202
209
|
model: FOLDER_MODEL_NAME,
|
|
203
210
|
file: 'entities/folder.json',
|
|
204
211
|
document,
|
|
212
|
+
warnings,
|
|
205
213
|
}
|
|
206
214
|
}
|
package/src/uwx/index.js
CHANGED
|
@@ -50,20 +50,21 @@ export {
|
|
|
50
50
|
isSiteRelativeExtensionUrl,
|
|
51
51
|
} from './site.js'
|
|
52
52
|
export {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
} from './
|
|
53
|
+
resolveQueriesConfig,
|
|
54
|
+
queriesYmlPath,
|
|
55
|
+
QUERIES_YML_RELPATH,
|
|
56
|
+
} from './queries-config.js'
|
|
57
57
|
export { upsertYamlScalar } from './yaml-upsert.js'
|
|
58
58
|
export { buildFolderEntity,
|
|
59
59
|
collectFolderItemUuids,
|
|
60
60
|
stampFolderItemUuids
|
|
61
61
|
} from './folder.js'
|
|
62
62
|
export {
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
recordsToProject,
|
|
64
|
+
declarationsToQueriesYml,
|
|
65
|
+
folderToRecordsYml,
|
|
65
66
|
findRecordFileByUuid,
|
|
66
|
-
} from './
|
|
67
|
+
} from './records-project.js'
|
|
67
68
|
export {
|
|
68
69
|
siteInfoToConfig,
|
|
69
70
|
sectionRecordToFile,
|
|
@@ -90,16 +91,36 @@ export {
|
|
|
90
91
|
collectSiteUnits,
|
|
91
92
|
walkSiteUnits,
|
|
92
93
|
collectUnitUuids,
|
|
94
|
+
collectQueryUuids,
|
|
93
95
|
stampUnitUuids,
|
|
94
96
|
} from './site-diff.js'
|
|
95
97
|
export {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
recordsToEntities,
|
|
99
|
+
buildRecordEntities,
|
|
98
100
|
filterChanged,
|
|
99
|
-
|
|
101
|
+
emitRecordSyncPackage,
|
|
100
102
|
entityContentHash,
|
|
101
|
-
} from './
|
|
102
|
-
export {
|
|
103
|
+
} from './records.js'
|
|
104
|
+
export { readEntityFile, parseFrontmatter } from './entity-source.js'
|
|
105
|
+
|
|
106
|
+
// The folder's own vocabulary, so a verb can ask what `records.yml` SAYS without
|
|
107
|
+
// re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
|
|
108
|
+
// `site/records-config.js` does not reach a caller until it is named here.
|
|
109
|
+
// The pool's grammar — a schema ref to its directory and back. ⛔ Allowlist: an
|
|
110
|
+
// export added to `site/entity-pool.js` does not reach a caller until it is here.
|
|
111
|
+
export {
|
|
112
|
+
poolDirsForSchema,
|
|
113
|
+
schemaForPoolDirs,
|
|
114
|
+
ENTITIES_DIR,
|
|
115
|
+
} from '../site/entity-pool.js'
|
|
116
|
+
|
|
117
|
+
export {
|
|
118
|
+
readRecordsConfig,
|
|
119
|
+
RECORDS_YML_RELPATH,
|
|
120
|
+
FOLDER_MISSING,
|
|
121
|
+
FOLDER_EMPTY,
|
|
122
|
+
FOLDER_DECLARED,
|
|
123
|
+
} from '../site/records-config.js'
|
|
103
124
|
export {
|
|
104
125
|
findRecordFile,
|
|
105
126
|
backfillUuid,
|
package/src/uwx/locale-sync.js
CHANGED
|
@@ -28,8 +28,8 @@ const FREEFORM_MANIFEST = '.manifest.json'
|
|
|
28
28
|
|
|
29
29
|
// The `locales/` directory for a site (the i18n localesDir default; `paths` can
|
|
30
30
|
// override it, but the default is `locales` — keep this the single place to change).
|
|
31
|
-
// `subdir` scopes a lane: '' → site-content (locales/), '
|
|
32
|
-
//
|
|
31
|
+
// `subdir` scopes a lane: '' → site-content (locales/), 'records' → record
|
|
32
|
+
// translations (locales/records/), matching the i18n manifest layout.
|
|
33
33
|
export function localesDir(siteRoot, subdir = '') {
|
|
34
34
|
return subdir ? join(siteRoot, 'locales', subdir) : join(siteRoot, 'locales')
|
|
35
35
|
}
|
|
@@ -15,9 +15,10 @@ import { join, resolve, dirname } from 'node:path'
|
|
|
15
15
|
import { randomBytes } from 'node:crypto'
|
|
16
16
|
import yaml from 'js-yaml'
|
|
17
17
|
import { proseMirrorToMarkdown, serializeFrontmatter } from '@uniweb/content-writer'
|
|
18
|
-
import { parseFrontmatter } from './
|
|
18
|
+
import { parseFrontmatter } from './entity-source.js'
|
|
19
19
|
import { renderEntityDocument } from './backfill.js'
|
|
20
|
-
import {
|
|
20
|
+
import { queriesYmlPath } from './queries-config.js'
|
|
21
|
+
import { recordsYmlPath } from '../site/records-config.js'
|
|
21
22
|
|
|
22
23
|
// Frontmatter keys that belong to the CCA framework / the developer's local
|
|
23
24
|
// authoring, not to externally-editable params. On a section write an existing
|
|
@@ -259,16 +260,41 @@ export function writeMergedYaml(filePath, projected, managedKeys) {
|
|
|
259
260
|
}
|
|
260
261
|
|
|
261
262
|
/**
|
|
262
|
-
* Merge `
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
* `
|
|
263
|
+
* Merge `queries` into `queries.yml` (shallow). Preserves queries not in the
|
|
264
|
+
* incoming set; each incoming one is replaced wholesale. Same key-preserving
|
|
265
|
+
* (comment-dropping) bar as `writeSiteConfig`.
|
|
266
|
+
*
|
|
267
|
+
* ⛔ `queries.yml` IS THE MAP — there is no root key, so `queries` is passed bare.
|
|
268
|
+
* The predecessor wrote `{ collections: {...} }` into `collections.yml`; handing
|
|
269
|
+
* that same wrapper to this file would produce a query NAMED `collections`, which
|
|
270
|
+
* is a silent corruption rather than an error. The one caller was updated with it.
|
|
271
|
+
*
|
|
272
|
+
* @param {string} siteRoot
|
|
273
|
+
* @param {object} queries - `{ [name]: decl }`, bare
|
|
274
|
+
* @returns {'updated'|'unchanged'}
|
|
275
|
+
*/
|
|
276
|
+
export function writeQueriesConfig(siteRoot, queries) {
|
|
277
|
+
return mergeYamlConfig(queriesYmlPath(siteRoot), queries)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Write `records.yml` — the site's folder, as a LIST.
|
|
282
|
+
*
|
|
283
|
+
* ⛔ A FULL WRITE, NOT A MERGE, and that is the one place this differs from every
|
|
284
|
+
* other projected config. `records.yml` IS the folder: concrete refs on both
|
|
285
|
+
* sides, nothing to invert, so a pull is a mirror rather than an update. There is
|
|
286
|
+
* also nothing a shallow merge could mean here — the file is a sequence, and
|
|
287
|
+
* merging two lists either duplicates entries or silently drops them.
|
|
288
|
+
*
|
|
289
|
+
* ⚠️ WHICH IS WHY AN EMPTY LIST IS NOT WRITTEN AS A FILE-WITH-NOTHING BY ACCIDENT.
|
|
290
|
+
* An empty `records.yml` is DESTRUCTIVE on the next push — it says the folder holds
|
|
291
|
+
* nothing. A pull that carried no folder must leave the file alone, so the caller
|
|
292
|
+
* decides, and this only writes what it was actually given.
|
|
293
|
+
*
|
|
268
294
|
* @returns {'updated'|'unchanged'}
|
|
269
295
|
*/
|
|
270
|
-
export function
|
|
271
|
-
return
|
|
296
|
+
export function writeRecordsConfig(siteRoot, entries) {
|
|
297
|
+
return writeYamlFile(recordsYmlPath(siteRoot), entries)
|
|
272
298
|
}
|
|
273
299
|
|
|
274
300
|
/**
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Moved to `../site/queries-config.js` — a site's collection declarations are
|
|
2
|
+
// a site-build question first, and keeping the resolver here meant the build could
|
|
3
|
+
// not reach it and answered differently. Re-exported so no caller moved.
|
|
4
|
+
export {
|
|
5
|
+
resolveQueriesConfig,
|
|
6
|
+
queriesYmlPath,
|
|
7
|
+
defaultSchema,
|
|
8
|
+
deferredFromSchema,
|
|
9
|
+
foundationDataSchemas,
|
|
10
|
+
QUERIES_YML_RELPATH,
|
|
11
|
+
} from '../site/queries-config.js'
|