@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.
Files changed (41) hide show
  1. package/package.json +7 -7
  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 +25 -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 +71 -0
  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 +85 -77
  26. package/src/uwx/index.js +33 -12
  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 +24 -1
  33. package/src/uwx/site-project.js +9 -6
  34. package/src/uwx/site.js +179 -23
  35. package/src/uwx/sync-package.js +37 -16
  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
package/src/uwx/folder.js CHANGED
@@ -1,20 +1,28 @@
1
- // Build the one `@uniweb/folder` entity that organizes a site's collection records.
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 collection-record entities, and
4
- // — when the site has collections — ONE `@uniweb/folder` entity describing how those
5
- // records are organized. `@uniweb/folder` is a normal section-keyed entity (the
6
- // "structured content all the way down" invariant): its document is `{ info?, contents }`.
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: "<collection>/<slug>"` while brand-new (resolved within this payload).
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
- // Organization comes from `collections.yml::folders` (a VIRTUAL tree, decoupled from
16
- // the on-disk layout) when present; otherwise the default is one branch per
17
- // collection, its records as leaves mirroring the `collections/` subfolders.
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
- // One record a `ref` leaf. The folder's `contents` field is polymorphic (it can
27
- // reference any Model), so the ref uses the entity_ref OPEN form `{ model, entity }`
28
- // — not a bare uuid (a bare uuid is only valid when the field pins a single model).
29
- // Known uuid → `entry: { model, entity: <uuid> }`; brand-new → `$ref` handle
30
- // (resolved within this payload to the minted entity).
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 `<collection>/<slug>` payload-local handle
47
+ else leaf.$ref = entity.id // the payload-local handle
39
48
  return leaf
40
49
  }
41
50
 
42
- // Group record entities by their collection (the `<collection>` prefix of `$id`).
43
- function groupByCollection(recordEntities) {
44
- const groups = new Map()
45
- for (const e of recordEntities) {
46
- const collection = e.collection ?? String(e.id).split('/')[0]
47
- if (!groups.has(collection)) groups.set(collection, [])
48
- groups.get(collection).push(e)
49
- }
50
- return groups
51
- }
52
-
53
- // Default org: one branch per collection (declaration order), records as leaves.
54
- function defaultContents(groups) {
55
- const contents = []
56
- for (const [collection, records] of groups) {
57
- contents.push({
58
- kind: 'branch',
59
- path_segment: collection,
60
- $children: records.map(refLeaf),
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
- if (node && typeof node === 'object') {
80
- const segment = node.segment ?? node.path_segment
81
- const branch = { kind: 'branch', path_segment: segment }
82
- if (node.label !== undefined) branch.name = node.label
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
- return null
78
+ out.push(refLeaf(entity))
95
79
  }
96
- return folders.map(buildNode).filter(Boolean)
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 there are no records.
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 collection-record entities (full
177
- * set, BEFORE send-only-changed filtering), each `{ id, uuid, slug, collection? }`
178
- * @param {Array|null} [params.folders] - `collections.yml::folders` virtual org
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, collection: '@folder' }|null}
166
+ * @returns {{ id, uuid, model, file, document, warnings }|null}
183
167
  */
184
- export function buildFolderEntity({ recordEntities, folders = null, itemUuids = null }) {
185
- if (!Array.isArray(recordEntities) || recordEntities.length === 0) return null
186
- const groups = groupByCollection(recordEntities)
187
- const contents = folders ? virtualContents(folders, groups) : defaultContents(groups)
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
- resolveCollectionsConfig,
54
- collectionsYmlPath,
55
- COLLECTIONS_YML_RELPATH,
56
- } from './collections-config.js'
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
- collectionsToProject,
64
- declarationsToCollectionsYml,
63
+ recordsToProject,
64
+ declarationsToQueriesYml,
65
+ folderToRecordsYml,
65
66
  findRecordFileByUuid,
66
- } from './collections-project.js'
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
- collectionRecordsToEntities,
97
- buildCollectionEntities,
98
+ recordsToEntities,
99
+ buildRecordEntities,
98
100
  filterChanged,
99
- emitCollectionSyncPackage,
101
+ emitRecordSyncPackage,
100
102
  entityContentHash,
101
- } from './collections.js'
102
- export { readCollectionRecords, parseFrontmatter } from './collection-source.js'
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,
@@ -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/), 'collections' → collection
32
- // records (locales/collections/), matching the i18n manifest layout.
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 './collection-source.js'
18
+ import { parseFrontmatter } from './entity-source.js'
19
19
  import { renderEntityDocument } from './backfill.js'
20
- import { collectionsYmlPath } from './collections-config.js'
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 `config` into `collections/collections.yml` (shallow). Preserves sibling
263
- * keys the update doesn't touch the folder `$uuid`, `sync`, `folders`, and any
264
- * collections not in the incoming set. A `collections:` object value is merged one
265
- * level deep (per-collection), so each declaration is replaced wholesale while
266
- * untouched collections stay. Same key-preserving (comment-dropping) bar as
267
- * `writeSiteConfig`.
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 writeCollectionsConfig(siteRoot, config) {
271
- return mergeYamlConfig(collectionsYmlPath(siteRoot), config)
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'