@uniweb/build 0.27.2 → 0.28.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.27.2",
3
+ "version": "0.28.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,17 +59,17 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/projections": "^0.4.0",
62
+ "@uniweb/projections": "^0.5.0",
63
63
  "@uniweb/semantic-parser": "^1.3.1",
64
- "@uniweb/content-writer": "^0.3.4",
65
64
  "@uniweb/schemas": "^0.2.11",
66
- "@uniweb/theming": "^0.1.15"
65
+ "@uniweb/theming": "^0.1.15",
66
+ "@uniweb/content-writer": "^0.3.4"
67
67
  },
68
68
  "optionalDependencies": {
69
- "@uniweb/semantic-parser": "^1.3.1",
69
+ "@uniweb/content-reader": "^1.2.4",
70
70
  "@uniweb/schemas": "^0.2.11",
71
- "@uniweb/runtime": "^0.13.0",
72
- "@uniweb/content-reader": "^1.2.4"
71
+ "@uniweb/semantic-parser": "^1.3.1",
72
+ "@uniweb/runtime": "^0.13.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
package/src/uwx/folder.js CHANGED
@@ -96,6 +96,76 @@ function virtualContents(folders, groups) {
96
96
  return folders.map(buildNode).filter(Boolean)
97
97
  }
98
98
 
99
+
100
+ /**
101
+ * Walk a folder document's `contents` tree, visiting every item with the
102
+ * slash-joined `path_segment` chain that addresses it.
103
+ *
104
+ * ⛔ IT MUST RECURSE INTO `$children`. `contents` is SELF-NESTING: a walk of the
105
+ * top level sees the branches and misses every record under them — which is 6 of
106
+ * the 7 entries in a two-collection site. *(Named by the backend lane, 2026-08-27,
107
+ * before it could be got wrong.)*
108
+ */
109
+ function walkFolderItems(contents, cb, prefix = '') {
110
+ for (const item of contents || []) {
111
+ if (!item || typeof item !== 'object') continue
112
+ const seg = typeof item.path_segment === 'string' ? item.path_segment : null
113
+ const path = seg ? (prefix ? `${prefix}/${seg}` : seg) : prefix
114
+ if (seg) cb(path, item)
115
+ walkFolderItems(item.$children, cb, path)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Harvest per-item identity from the folder document the backend returns.
121
+ *
122
+ * ⭐ THE KEY IS THE `path_segment` CHAIN, and it is the right one because the
123
+ * backend's own model declares `path_segment` SIBLING-UNIQUE — so the chain is
124
+ * unique within the folder, stable across pushes, and derivable identically on
125
+ * both sides without either lane holding the other's ids.
126
+ *
127
+ * @param {object} doc - a stored `@uniweb/folder` document (`{ contents: [...] }`)
128
+ * @returns {Record<string,string>} path → `$uuid`
129
+ */
130
+ export function collectFolderItemUuids(doc) {
131
+ const out = {}
132
+ walkFolderItems(doc?.contents, (path, item) => {
133
+ if (typeof item.$uuid === 'string' && item.$uuid) out[path] = item.$uuid
134
+ })
135
+ return out
136
+ }
137
+
138
+ /**
139
+ * Stamp known `$uuid`s onto a folder document about to be sent, so the backend
140
+ * matches its stored rows instead of reading every item as new.
141
+ *
142
+ * ⛔ WHY THIS EXISTS. `contents` is a `multi` section: an item without a `$uuid`
143
+ * is a new row, so re-sending the folder without identity would replace every
144
+ * placement. The backend refuses that outright (`identity_required`) — correctly
145
+ * — and the refusal is what a `publish` after a `push` used to hit, because
146
+ * send-only-changed skips the unchanged RECORDS and re-sends the FOLDER alone.
147
+ *
148
+ * ⚠️ The folder ENTITY still carries no `$uuid` — that stays the backend's, keyed
149
+ * from the site-content uuid. This is about its ITEMS, and the two were conflated
150
+ * by a comment in this file that was true of the entity and false of its contents.
151
+ *
152
+ * @returns {{ stamped: number, unknown: number }}
153
+ */
154
+ export function stampFolderItemUuids(doc, pathToUuid = {}) {
155
+ let stamped = 0
156
+ let unknown = 0
157
+ walkFolderItems(doc?.contents, (path, item) => {
158
+ const uuid = pathToUuid[path]
159
+ if (uuid) {
160
+ item.$uuid = uuid
161
+ stamped++
162
+ } else {
163
+ unknown++
164
+ }
165
+ })
166
+ return { stamped, unknown }
167
+ }
168
+
99
169
  /**
100
170
  * Build the `@uniweb/folder` entity descriptor, or null when there are no records.
101
171
  *
@@ -106,9 +176,12 @@ function virtualContents(folders, groups) {
106
176
  * @param {object[]} params.recordEntities - the collection-record entities (full
107
177
  * set, BEFORE send-only-changed filtering), each `{ id, uuid, slug, collection? }`
108
178
  * @param {Array|null} [params.folders] - `collections.yml::folders` virtual org
179
+ * @param {Record<string,string>} [params.itemUuids] - path → `$uuid`, harvested
180
+ * from the folder document a previous push returned. Absent on a first
181
+ * push, where every item is genuinely new.
109
182
  * @returns {{ id, uuid, model, file, document, collection: '@folder' }|null}
110
183
  */
111
- export function buildFolderEntity({ recordEntities, folders = null }) {
184
+ export function buildFolderEntity({ recordEntities, folders = null, itemUuids = null }) {
112
185
  if (!Array.isArray(recordEntities) || recordEntities.length === 0) return null
113
186
  const groups = groupByCollection(recordEntities)
114
187
  const contents = folders ? virtualContents(folders, groups) : defaultContents(groups)
@@ -118,6 +191,9 @@ export function buildFolderEntity({ recordEntities, folders = null }) {
118
191
  $model: FOLDER_MODEL_NAME,
119
192
  contents,
120
193
  }
194
+ // Re-arm placement identity. Without it a second send reads as "every item is
195
+ // new" and the backend refuses rather than replacing them all.
196
+ if (itemUuids && Object.keys(itemUuids).length) stampFolderItemUuids(document, itemUuids)
121
197
 
122
198
  return {
123
199
  id: FOLDER_ENTITY_KEY,
package/src/uwx/index.js CHANGED
@@ -55,7 +55,10 @@ export {
55
55
  COLLECTIONS_YML_RELPATH,
56
56
  } from './collections-config.js'
57
57
  export { upsertYamlScalar } from './yaml-upsert.js'
58
- export { buildFolderEntity } from './folder.js'
58
+ export { buildFolderEntity,
59
+ collectFolderItemUuids,
60
+ stampFolderItemUuids
61
+ } from './folder.js'
59
62
  export {
60
63
  collectionsToProject,
61
64
  declarationsToCollectionsYml,
@@ -277,6 +277,12 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
277
277
  const folder = buildFolderEntity({
278
278
  recordEntities: col.entities,
279
279
  folders: col.colConfig?.folders ?? null,
280
+ // Placement identity from the folder document a previous push returned.
281
+ // Absent on a first push — every item is genuinely new then. Its ABSENCE on a
282
+ // later push is what made `publish` after `push` fail: send-only-changed skips
283
+ // the unchanged records and re-sends the folder ALONE, so the folder is exactly
284
+ // the payload whose item identity has to survive.
285
+ ...(opts.folderItemUuids ? { itemUuids: opts.folderItemUuids } : {}),
280
286
  })
281
287
 
282
288
  const siteDoc = includeSite ? await siteProjectToDocument(siteRoot, { sourceLocale }) : null