@uniweb/build 0.55.1 → 0.57.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/src/uwx/folder.js CHANGED
@@ -12,29 +12,41 @@
12
12
  // or `$ref: "<id>"` while brand-new (resolved within this payload).
13
13
  // - a BRANCH is a sub-folder: `{ kind: 'branch', name, label?, $children }`.
14
14
  //
15
- // ⭐ `name` IS THE HANDLE — the URL segment, sibling-unique, the records service's `$name` —
16
- // and `label` is the display text, a localized map (`{ en: "Blog" }`). The store
15
+ // ⭐ A LEAF'S `name` IS ITS RECORD'S HANDLE — the slug, the records service's `$name` —
16
+ // and a branch's `name` is the segment a query's `scope:` names. `label` is a branch's
17
+ // display text, a localized map (`{ en: "Blog" }`).
18
+ //
19
+ // ⛔ A FOLDER IS NOT A URL MAP, AND ITS NAMES ARE NOT UNIQUE ACROSS SCHEMAS. It is a
20
+ // content pool organized for the curator's convenience [Diego, 2026-09-21]; a slug is
21
+ // resolved by a (parametric) query over one schema, never by where a record sits, and
22
+ // mapping a URL to a folder path takes an explicit `scope: :dir` on a query. So
23
+ // `article/intro` and `person/intro` side by side in one folder is ordinary. *(This
24
+ // note called `name` "the URL segment, sibling-unique" until 2026-09-21, and that
25
+ // wording led straight to a push refusing two such records.)* The store
17
26
  // renamed the pair on 2026-09-04 (`path_segment` → `name`; the old display `name`
18
27
  // → `label`); this emitter writes the new shape only and the pull reader
19
28
  // (`records-project.js`) reads the new shape only. No alias on either side: the
20
29
  // old key's PRESENCE was the version signal, and there is no population to
21
30
  // carry.
22
31
  //
23
- // ⭐ THE ORGANIZATION IS AUTHORED, IN `records.yml`, AND IT IS THE ONLY SOURCE.
24
- // It used to be DERIVED — one branch per collection, mirroring the `collections/`
25
- // subfolders, with an optional `collections.yml::folders` virtual tree layered
26
- // over it. Both are gone, and the difference is the point: a folder is a thing
27
- // the author states, not a shadow of a directory layout. `records.yml` also
28
- // decides WHAT syncs at all, since listing an entity is what makes it a record.
32
+ // ⭐ WHAT IS IN THE FOLDER IS THE RECORDS DIRECTORY; HOW IT IS ORGANIZED IS
33
+ // `records.yml` (ruled 2026-09-21 [Diego]). Every file in `records/` is a record and
34
+ // sits at the top of the folder unless `records.yml` places it in a sub-folder. The
35
+ // organization used to be DERIVED — one branch per collection, mirroring the
36
+ // `collections/` subfolders — and the difference is the point: a sub-folder is a
37
+ // thing the author states, never a shadow of the schema folders, which declare a
38
+ // model and nothing else.
29
39
  //
30
- // ⛔ SO THERE IS NO DEFAULT. A site with no `records.yml` has no folder and syncs
31
- // no records — which is the model's `missing ⇒ inert` ruling, not an empty folder.
32
- // Do not reintroduce a fallback grouping: it would resurrect exactly the
33
- // three-jobs-in-one-directory conflation the layout was changed to remove.
40
+ // ⛔ A SITE WITH NO RECORDS DIRECTORY SENDS NO FOLDER, which is inert: the backend's
41
+ // folder is left as it is, so a site whose records live only there is not emptied
42
+ // by a push of its pages. A directory holding no records sends an EMPTY folder,
43
+ // which removes what is there — the CLI asks first. (Until 2026-09-21 both states
44
+ // were `records.yml`'s — missing and empty — when that file was the membership list.)
34
45
  //
35
46
  // The folder carries NO `$uuid` of its own: the backend owns the site's
36
47
  // `@uniweb/folder` and resolves it from the site-content uuid (the folder sync lane
37
- // is keyed by `site.yml::$uuid`). The framework never holds a folder uuid.
48
+ // is keyed by the site's uuid on that backend, from `sync.json`). The framework
49
+ // never holds a folder uuid.
38
50
 
39
51
  export const FOLDER_MODEL_NAME = '@uniweb/folder'
40
52
  export const FOLDER_ENTITY_KEY = '@folder'
@@ -57,7 +69,7 @@ function refLeaf(entity) {
57
69
  }
58
70
 
59
71
  /**
60
- * Turn the resolved `records.yml` tree into folder `contents`.
72
+ * Turn the placed records into folder `contents`.
61
73
  *
62
74
  * ⛔ A LEAF WHOSE ENTITY IS MISSING IS DROPPED AND REPORTED, never emitted empty.
63
75
  * A `ref` with neither `entry` nor `$ref` is a placement pointing at nothing —
@@ -91,9 +103,19 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
91
103
  return out
92
104
  }
93
105
 
106
+ // The uuid of the record a leaf references — once minted, `entry: { model, entity }`
107
+ // (a bare uuid is tolerated). A brand-new record's leaf carries `$ref` instead.
108
+ function recordOf(item) {
109
+ if (item?.kind !== 'ref') return null
110
+ const e = item.entry
111
+ const uuid = e && typeof e === 'object' ? e.entity : e
112
+ return typeof uuid === 'string' && uuid ? uuid : null
113
+ }
114
+
94
115
  /**
95
- * Walk a folder document's `contents` tree, visiting every item with the
96
- * slash-joined `name` chain that addresses it.
116
+ * Walk a folder document's `contents` tree, visiting every item with the keys that
117
+ * address it: its slash-joined `name` chain, whether that chain is UNIQUE among its
118
+ * siblings, and — for a leaf — the uuid of the record it references.
97
119
  *
98
120
  * ⛔ IT MUST RECURSE INTO `$children`. `contents` is SELF-NESTING: a walk of the
99
121
  * top level sees the branches and misses every record under them — which is 6 of
@@ -101,11 +123,15 @@ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
101
123
  * before it could be got wrong.)*
102
124
  */
103
125
  function walkFolderItems(contents, cb, prefix = '') {
104
- for (const item of contents || []) {
105
- if (!item || typeof item !== 'object') continue
126
+ const items = (contents || []).filter((item) => item && typeof item === 'object')
127
+ const count = new Map()
128
+ for (const item of items) {
129
+ if (typeof item.name === 'string') count.set(item.name, (count.get(item.name) || 0) + 1)
130
+ }
131
+ for (const item of items) {
106
132
  const seg = typeof item.name === 'string' ? item.name : null
107
133
  const path = seg ? (prefix ? `${prefix}/${seg}` : seg) : prefix
108
- if (seg) cb(path, item)
134
+ if (seg) cb({ path, unique: count.get(seg) === 1, record: recordOf(item) }, item)
109
135
  walkFolderItems(item.$children, cb, path)
110
136
  }
111
137
  }
@@ -113,18 +139,24 @@ function walkFolderItems(contents, cb, prefix = '') {
113
139
  /**
114
140
  * Harvest per-item identity from the folder document the backend returns.
115
141
  *
116
- * ⭐ THE KEY IS THE `name` CHAIN, and it is the right one because the backend's
117
- * own model declares `name` SIBLING-UNIQUE — so the chain is
118
- * unique within the folder, stable across pushes, and derivable identically on
119
- * both sides without either lane holding the other's ids.
142
+ * ⭐ A PLACEMENT IS KEYED BY THE RECORD IT REFERENCES — `@<record uuid>` — because a
143
+ * record has one placement, so that key is unique in the folder whatever the names
144
+ * are. A branch, and a leaf whose name no sibling shares, is ALSO keyed by its `name`
145
+ * chain, which is what every placement map banked before 2026-09-21 holds.
146
+ *
147
+ * ⛔ The chain alone assumed no two siblings share a name. Two records of different
148
+ * schemas can (`article/intro`, `person/intro`), and keyed by `intro` both would
149
+ * have been stamped with one uuid on the next push.
120
150
  *
121
151
  * @param {object} doc - a stored `@uniweb/folder` document (`{ contents: [...] }`)
122
- * @returns {Record<string,string>} path → `$uuid`
152
+ * @returns {Record<string,string>} key → `$uuid` — `@<record uuid>`, or a `name` chain
123
153
  */
124
154
  export function collectFolderItemUuids(doc) {
125
155
  const out = {}
126
- walkFolderItems(doc?.contents, (path, item) => {
127
- if (typeof item.$uuid === 'string' && item.$uuid) out[path] = item.$uuid
156
+ walkFolderItems(doc?.contents, ({ path, unique, record }, item) => {
157
+ if (typeof item.$uuid !== 'string' || !item.$uuid) return
158
+ if (record) out[`@${record}`] = item.$uuid
159
+ if (unique) out[path] = item.$uuid
128
160
  })
129
161
  return out
130
162
  }
@@ -143,15 +175,23 @@ export function collectFolderItemUuids(doc) {
143
175
  * from the site-content uuid. This is about its ITEMS, and the two were conflated
144
176
  * by a comment in this file that was true of the entity and false of its contents.
145
177
  *
178
+ * ⭐ A leaf takes the uuid banked under its record (`@<record uuid>`) first, and its
179
+ * `name` chain only when no sibling shares the name — the one key an older map has.
180
+ * ⛔ No uuid is stamped twice: two items claiming one row is the failure the record
181
+ * key exists to prevent, so a second claimant goes out without one, as a new row.
182
+ *
146
183
  * @returns {{ stamped: number, unknown: number }}
147
184
  */
148
185
  export function stampFolderItemUuids(doc, pathToUuid = {}) {
149
186
  let stamped = 0
150
187
  let unknown = 0
151
- walkFolderItems(doc?.contents, (path, item) => {
152
- const uuid = pathToUuid[path]
153
- if (uuid) {
188
+ const claimed = new Set()
189
+ walkFolderItems(doc?.contents, ({ path, unique, record }, item) => {
190
+ let uuid = record ? pathToUuid[`@${record}`] : undefined
191
+ if (!uuid && unique) uuid = pathToUuid[path]
192
+ if (uuid && !claimed.has(uuid)) {
154
193
  item.$uuid = uuid
194
+ claimed.add(uuid)
155
195
  stamped++
156
196
  } else {
157
197
  unknown++
@@ -169,8 +209,9 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
169
209
  * @param {object} params
170
210
  * @param {object[]} params.recordEntities - the record entities (full set, BEFORE
171
211
  * send-only-changed filtering), each `{ id, uuid, slug, model }`
172
- * @param {Array} params.folderNodes - the resolved `records.yml` tree
173
- * @param {boolean} [params.declared] - whether `records.yml` EXISTS. See below.
212
+ * @param {Array} params.folderNodes - the placed records: `records.yml`'s sub-folders,
213
+ * then every record at the top
214
+ * @param {boolean} [params.declared] - whether the records DIRECTORY exists. See below.
174
215
  * @param {Record<string,string>} [params.itemUuids] - path → `$uuid`, harvested
175
216
  * from the folder document a previous push returned. Absent on a first
176
217
  * push, where every item is genuinely new.
@@ -181,17 +222,16 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
181
222
  export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null, sourceLocale = 'en' }) {
182
223
  // ⛔ `missing` AND `empty` ARE DIFFERENT, AND THE ASYMMETRY IS DELIBERATE.
183
224
  //
184
- // no records.yml → null. INERT: nothing is sent, and the server's
185
- // folder is left exactly as it is.
186
- // records.yml, empty → a folder with `contents: []`. DESTRUCTIVE: it says
187
- // the folder holds nothing, so the backend removes
188
- // what is there.
225
+ // no records directory → null. INERT: nothing is sent, and the backend's
226
+ // folder is left exactly as it is.
227
+ // a directory, no records → a folder with `contents: []`. DESTRUCTIVE: it says
228
+ // the folder holds nothing, so the backend removes
229
+ // what is there.
189
230
  //
190
- // ⭐ The safe state is the ABSENCE of a file and the destructive act requires
191
- // affirmatively CREATING one — so a live folder cannot be wiped by deleting
192
- // something. ⛔ Do not "simplify" these into one behaviour to avoid the
193
- // placeholder hazard (an empty file created meaning to fill it in): that would
194
- // delete a capability to avoid writing a prompt. The CLI asks, with a count.
231
+ // ⭐ The safe state is the ABSENCE of the directory, so a site that never had
232
+ // records — or keeps them only on the backend — cannot empty the backend's folder
233
+ // by pushing. ⛔ Do not "simplify" these into one behaviour: that would delete a
234
+ // capability to avoid writing a prompt. The CLI asks, with a count.
195
235
  const empty = !Array.isArray(folderNodes) || folderNodes.length === 0
196
236
  if (empty && !declared) return null
197
237
 
@@ -200,17 +240,17 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
200
240
 
201
241
  const missing = []
202
242
  const contents = contentsFromNodes(folderNodes, byEntityId, missing, sourceLocale)
203
- // ⚠️ `id` IS THE ENTITY'S POOL PATH, NOT A FOLDER PATH — say so, because the two
204
- // read identically and a reader who takes it for a placement concludes the
205
- // emitter is dropping a branch it never had. *(Measured 2026-08-31: the backend
206
- // lane read `folder: "articles/outdoor-hygge"` as a placement under an
243
+ // ⚠️ `id` IS THE RECORD'S PATH IN THE RECORDS DIRECTORY, NOT A FOLDER PATH — say
244
+ // so, because the two read identically and a reader who takes it for a placement
245
+ // concludes the emitter is dropping a branch it never had. *(Measured 2026-08-31:
246
+ // the backend lane read `folder: "articles/outdoor-hygge"` as a placement under an
207
247
  // `articles` branch and opened a channel about a missing branch node; the string
208
- // was naming `entities/articles/outdoor-hygge.md`.)*
248
+ // was naming the file `articles/outdoor-hygge.md`.)*
209
249
  const warnings = missing.map(
210
250
  (id) =>
211
- `records.yml: "${id}" — a path under entities/ — is listed, but no record ` +
212
- `entity was produced for it (check that its schema resolves). The placement ` +
213
- `was dropped rather than sent pointing at nothing.`
251
+ `the record "${id}" — a path under the records directory — is placed in the ` +
252
+ `folder, but no record entity was produced for it. The placement was dropped ` +
253
+ `rather than sent pointing at nothing.`
214
254
  )
215
255
 
216
256
  const document = {
package/src/uwx/index.js CHANGED
@@ -44,7 +44,6 @@ export {
44
44
  emitSiteSyncPackage,
45
45
  writeSiteEntityUuid,
46
46
  writeSiteOrg,
47
- writeSiteBackend,
48
47
  extensionDeclaration,
49
48
  isExtensionUrl,
50
49
  isSiteRelativeExtensionUrl,
@@ -77,14 +76,27 @@ export {
77
76
  localeFilePath,
78
77
  } from './locale-sync.js'
79
78
  export { emitSyncPackages } from './sync-package.js'
80
- export {
81
- readAssetMap,
82
- updateAssetMap,
79
+ // ⚠️ The asset MAP is `sync.json::backends.<origin>.assets` (sync-store.js, below).
80
+ // `readAssetMap` / `updateAssetMap` / `refForAssetId` / `ASSET_MAP_FILE` were removed
81
+ // on 2026-09-20: a single flat file cannot hold ids minted by more than one backend.
82
+ export { restoreAssetRefs, servedFingerprint } from './asset-map.js'
83
+ // `sync.json` — what each backend minted, keyed by origin. Absorbs `assets.json`
84
+ // above, `site.yml`'s identity keys and three maps out of the sync cache.
85
+ // Spec: kb/framework/reference/sync-json.md
86
+ export {
87
+ SYNC_STORE_FILE,
88
+ readSyncStore,
89
+ readBackendState,
90
+ listSyncedBackends,
91
+ updateBackendState,
92
+ updateBackendMap,
93
+ carryServed,
94
+ clearBackend,
95
+ clearBackendSections,
96
+ forgetSyncStore,
83
97
  refForAssetId,
84
- restoreAssetRefs,
85
- ASSET_MAP_FILE,
86
- servedFingerprint,
87
- } from './asset-map.js'
98
+ normalizeOrigin as normalizeBackendOrigin,
99
+ } from './sync-store.js'
88
100
  export {
89
101
  diffSiteUnits,
90
102
  describeSiteDiff,
@@ -104,23 +116,23 @@ export {
104
116
  } from './records.js'
105
117
  export { readEntityFile, parseFrontmatter } from './entity-source.js'
106
118
 
107
- // The folder's own vocabulary, so a verb can ask what `records.yml` SAYS without
108
- // re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
109
- // `site/records-config.js` does not reach a caller until it is named here.
110
- // The pool's grammar — a schema ref to its directory and back. ⛔ Allowlist: an
111
- // export added to `site/entity-pool.js` does not reach a caller until it is here.
119
+ // Where a site's records live, and the grammar of that directory — a schema ref to
120
+ // its folder and back. ⛔ Allowlist: an export added to `site/entity-pool.js` does
121
+ // not reach a caller until it is here.
112
122
  export {
113
123
  poolDirsForSchema,
114
124
  schemaForPoolDirs,
115
- ENTITIES_DIR,
125
+ readEntityPool,
126
+ resolveRecordsDir,
127
+ RECORDS_DIR,
116
128
  } from '../site/entity-pool.js'
117
129
 
130
+ // How the folder is organized, so a verb can ask what `records.yml` SAYS without
131
+ // re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
132
+ // `site/records-config.js` does not reach a caller until it is named here.
118
133
  export {
119
134
  readRecordsConfig,
120
135
  RECORDS_YML_RELPATH,
121
- FOLDER_MISSING,
122
- FOLDER_EMPTY,
123
- FOLDER_DECLARED,
124
136
  } from '../site/records-config.js'
125
137
  export {
126
138
  findRecordFile,