@uniweb/build 0.56.0 → 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.
@@ -1,48 +1,36 @@
1
- // `records.yml` — the site's FOLDER: what is in it, and where.
1
+ // `records.yml` — how the site's records folder is ORGANIZED. Optional.
2
2
  //
3
- // ⭐ LISTING AN ENTITY HERE IS WHAT MAKES IT A RECORD. An entity in `entities/`
4
- // exists; a record is a leaf entry in the site's folder. The backend's own gauge:
5
- // "An entity in no folder is not a record either: it exists, but cannot be
6
- // publicly fetched; placing a ref to it in a published folder is what makes one."
7
- // ⇒ An unreferenced entity is a draft, for free, with no flag to set.
3
+ // ⭐ IT DOES NOT SAY WHAT IS IN THE FOLDER (ruled 2026-09-21 [Diego]). Every file in
4
+ // `records/` is a record — placing it there is what makes one (`entity-pool.js`).
5
+ // This file only sorts records into sub-folders, so a site whose records are one
6
+ // flat set has no `records.yml` at all, which is the common case.
8
7
  //
9
- // ⛔ IT IS A DATABASE, NOT A PAGE SYSTEM. Records are data. They usually have no
10
- // structure at all — a flat pool, and THE COMMON CASE IS THREE LINES:
11
- //
12
- // - person/*.md
13
- // - publication/*.md
14
- // - project/*.md
8
+ // ⛔ UNTIL 2026-09-21 THIS FILE WAS THE MEMBERSHIP LIST: a bare string at the top
9
+ // level listed an entity and made it a record, an unlisted one was left out, and
10
+ // the file's absence or emptiness decided what a push did. All of that moved to
11
+ // the directory. A top-level path is refused rather than read — it would claim to
12
+ // select records while selecting nothing.
15
13
  //
16
14
  // ⛔ AND STRUCTURE IS QUERY SCOPE, NOT NAVIGATION. A `folder:` is an addressable
17
- // dimension a query slices on — `where: { path: { under: 'archive' } }` — never a
18
- // menu, a listing or a URL tree. The question is not "does this site want
19
- // sub-pages?" but "will a query ever ask for a SLICE rather than the whole pool?"
20
- // Most will not, which is why flat is the norm rather than a simplification.
21
- //
22
- // ⭐ THE SHAPE IS A LIST, and an entity gets no ceremony: a bare string IS an
23
- // entity path, one file or a pattern matching many. Only the exceptions announce
24
- // themselves — `url:`, `asset:`, `folder:`. (An earlier design keyed a map by
25
- // entry name; it charged every entry a key, a kind and often a label to buy
26
- // navigation ergonomics most sites never use. Measured on a realistic file: 7 of
27
- // 11 entries are just a path.)
15
+ // dimension a query slices on — `scope: archive` — never a menu, a listing or a URL
16
+ // tree. The question is not "does this site want sub-pages?" but "will a query ever
17
+ // ask for a SLICE rather than the whole set?" Most will not, which is why flat is
18
+ // the norm rather than a simplification.
28
19
  //
29
- // ⛔ NO QUERIES IN HERE. A computed subset is a named query, in `queries.yml`.
30
- // Curation enumerates; queries compute. Two constructs, two jobs.
20
+ // ⭐ THE SHAPE IS A LIST OF FOLDERS, each holding records by path — one file, or a
21
+ // pattern matching many — and further folders:
31
22
  //
32
- // ⭐ AND IT IS THE SYNC CONTROL. What syncs is exactly what this file references —
33
- // no flag, no inference. `missing` and `empty` differ deliberately:
23
+ // - folder: archive
24
+ // label: Publication Archive
25
+ // records:
26
+ // - publication/2025-*.md
34
27
  //
35
- // missing → inert. Do not sync; leave the server's folder untouched.
36
- // empty → destructive. Sync an empty folder, removing what is there.
28
+ // Every record no folder names sits at the top of the site's folder.
37
29
  //
38
- // The safe state is the ABSENCE of a file and the destructive act requires
39
- // affirmatively creating one, so a live folder cannot be wiped by deleting
40
- // something. ⛔ Do not "simplify" these into one behaviour — it would delete a
41
- // capability. The placeholder hazard (someone creates an empty file meaning to
42
- // fill it in) is guarded at the CLI with a count and a confirmation; the format
43
- // stays honest and the CLI does the asking.
30
+ // ⛔ NO QUERIES IN HERE. A computed subset is a named query, in `queries.yml`.
31
+ // Organizing places records; queries compute. Two constructs, two jobs.
44
32
  //
45
- // Model: entity · record · query · folder.
33
+ // Model: record · folder · query.
46
34
 
47
35
  import { existsSync } from 'node:fs'
48
36
  import { readFile } from 'node:fs/promises'
@@ -50,6 +38,7 @@ import { join } from 'node:path'
50
38
  import yaml from 'js-yaml'
51
39
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
52
40
  import { compareByNumericPrefix } from '../utils/numeric-prefix.js'
41
+ import { RECORDS_DIR } from './entity-pool.js'
53
42
 
54
43
  export const RECORDS_YML_RELPATH = 'records.yml'
55
44
 
@@ -58,13 +47,8 @@ export function recordsYmlPath(siteRoot) {
58
47
  return join(siteRoot, RECORDS_YML_RELPATH)
59
48
  }
60
49
 
61
- /** What `records.yml` says about syncing, before any entry is resolved. */
62
- export const FOLDER_MISSING = 'missing'
63
- export const FOLDER_EMPTY = 'empty'
64
- export const FOLDER_DECLARED = 'declared'
65
-
66
50
  /**
67
- * Match one entity path against a `records.yml` pattern.
51
+ * Match one record path against a `records.yml` pattern.
68
52
  *
69
53
  * ⛔ `*` DOES NOT CROSS A `/`, which is what a reader expects of a file pattern
70
54
  * and is NOT what `@uniweb/core`'s `globMatch` does. That one backs the `like`
@@ -101,62 +85,67 @@ export function slugForEntity(entity) {
101
85
  }
102
86
 
103
87
  /**
104
- * Read `records.yml`.
88
+ * Read `records.yml` — the folder's organization.
105
89
  *
106
- * ⛔ THE THREE STATES ARE NOT TWO. `missing` and `empty` mean different things
107
- * and the difference is load-bearing (see the header), so this reports which it
108
- * saw rather than collapsing them into "no entries".
90
+ * ⭐ MISSING AND EMPTY MEAN THE SAME: no sub-folders, every record at the top.
91
+ * ⛔ Until 2026-09-21 they differed — missing left a backend's folder alone, empty
92
+ * removed what was there — because this file decided what was in the folder. The
93
+ * `records/` directory decides that now, so no state of this file removes a record.
109
94
  *
110
- * @returns {Promise<{ state: string, entries: Array, error: string|null }>}
95
+ * ⛔ A MALFORMED FILE IS STILL AN ERROR: what it meant to organize cannot be guessed.
96
+ *
97
+ * @returns {Promise<{ exists: boolean, entries: Array, error: string|null }>}
111
98
  */
112
99
  export async function readRecordsConfig(siteRoot) {
113
100
  const file = join(siteRoot, RECORDS_YML_RELPATH)
114
- if (!existsSync(file)) return { state: FOLDER_MISSING, entries: [], error: null }
101
+ if (!existsSync(file)) return { exists: false, entries: [], error: null }
115
102
 
116
103
  let doc
117
104
  try {
118
105
  doc = yaml.load(await readFile(file, 'utf8'), YAML_OPTIONS)
119
106
  } catch (err) {
120
- return { state: FOLDER_MISSING, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
107
+ return { exists: true, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
121
108
  }
122
109
 
123
- if (doc === null || doc === undefined || (Array.isArray(doc) && doc.length === 0)) {
124
- return { state: FOLDER_EMPTY, entries: [], error: null }
125
- }
110
+ if (doc === null || doc === undefined) return { exists: true, entries: [], error: null }
126
111
  if (!Array.isArray(doc)) {
127
112
  return {
128
- state: FOLDER_MISSING,
113
+ exists: true,
129
114
  entries: [],
130
115
  error:
131
- `${RECORDS_YML_RELPATH} must be a LIST of what is in the folder, not ${typeof doc === 'object' ? 'a mapping' : 'a single value'}. ` +
132
- `The common case is three lines:\n - person/*.md\n - publication/*.md`,
116
+ `${RECORDS_YML_RELPATH} must be a LIST of folders, not ${typeof doc === 'object' ? 'a mapping' : 'a single value'}. ` +
117
+ `For example:\n - folder: archive\n records:\n - publication/2025-*.md`,
133
118
  }
134
119
  }
135
- return { state: FOLDER_DECLARED, entries: doc, error: null }
120
+ return { exists: true, entries: doc, error: null }
136
121
  }
137
122
 
138
123
  /**
139
- * Resolve the folder's entries against the entity pool.
124
+ * Place the site's records in its folder: the sub-folders `records.yml` declares,
125
+ * and the top of the folder for every record no sub-folder names.
140
126
  *
141
127
  * Every rule here is a guard, and each one exists because its failure was
142
128
  * SILENT. `entries: [artcles]` used to produce a real, reachable, empty path
143
129
  * with no warning at all.
144
130
  *
145
- * ⛔ ONE PLACEMENT PER ENTITY. Two entries matching one file is a hard error, not
131
+ * ⛔ ONE PLACEMENT PER RECORD. Two entries matching one file is a hard error, not
146
132
  * a second placement. The wire could carry many-to-many — `folder.js` nests, and
147
- * placements are keyed by their `name` chain — but `core/src/where.js`'s
148
- * `matchUnder` is STRING-ONLY, so a record with two paths would match nothing
149
- * under `where: { path: { under: … } }`, silently. Widening `under` is a
150
- * predicate the backend also evaluates natively, so it is a cross-lane change to
151
- * agree first, not to infer. Until then the file lane is the floor: one folder.
133
+ * a placement is banked by the record it references — but a record's `path` is one string:
134
+ * `@uniweb/core`'s `withinScope` matches nothing that is not a string, so a record
135
+ * with two paths would fall outside every `scope:`, silently. The records service
136
+ * evaluates `scope` natively, so widening it is a cross-lane change to agree first,
137
+ * not to infer. Until then the file lane is the floor: one folder.
152
138
  *
153
139
  * @param {Array} entries - the parsed `records.yml` list
154
- * @param {Array} pool - entities from `readEntityPool`
140
+ * @param {Array} pool - records from `readEntityPool`
141
+ * @param {object} [opts]
142
+ * @param {string} [opts.dir] - the records directory as written, for messages
155
143
  * @returns {{ nodes: Array, placements: Map, errors: string[], warnings: string[] }}
156
- * `nodes` is the folder tree (branches + entity placements, in file order);
157
- * `placements` maps an entity id to its `{ entity, path, slug }`.
144
+ * `nodes` is the folder tree — the declared sub-folders in file order, then every
145
+ * record no sub-folder names; `placements` maps EVERY record's id to its
146
+ * `{ entity, path, slug }`, `path` being `''` at the top of the folder.
158
147
  */
159
- export function resolveFolder(entries, pool) {
148
+ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
160
149
  const errors = []
161
150
  const warnings = []
162
151
  const placements = new Map()
@@ -167,7 +156,7 @@ export function resolveFolder(entries, pool) {
167
156
  for (const e of pool || []) {
168
157
  // ⚠️ KEYED BY THE FILE AS IT EXISTS, prefix and all — an author writing
169
158
  // `post/01-lab-opens.md` is naming a file they can see, not the slug it
170
- // produces. `poolPath` is that path, relative to `entities/`.
159
+ // produces. `poolPath` is that path, relative to `records/`.
171
160
  byRelPath.set(e.poolPath, e)
172
161
  }
173
162
 
@@ -177,7 +166,7 @@ export function resolveFolder(entries, pool) {
177
166
  if (prior) {
178
167
  errors.push(
179
168
  `${RECORDS_YML_RELPATH}: "${entity.relPath}" is placed twice — by ${prior} and by ${where}. ` +
180
- `An entity occupies one folder; a computed subset is a named query, not a second placement.`
169
+ `A record sits in one folder; a computed subset is a named query, not a second placement.`
181
170
  )
182
171
  return null
183
172
  }
@@ -193,6 +182,17 @@ export function resolveFolder(entries, pool) {
193
182
 
194
183
  if (typeof entry === 'string') {
195
184
  const pattern = entry.trim()
185
+ // ⛔ A PATH AT THE TOP LEVEL SELECTED RECORDS until 2026-09-21 — it was how a
186
+ // file became one. Every file in the directory is a record now, so the line
187
+ // would claim to choose while choosing nothing: refused, with the reason.
188
+ if (pathSegs.length === 0) {
189
+ errors.push(
190
+ `${RECORDS_YML_RELPATH}: ${where} ("${pattern}") lists records at the top level. Every file in ` +
191
+ `${dir}/ is a record already — ${RECORDS_YML_RELPATH} only sorts records into folders. ` +
192
+ `Remove the entry, or put it under a \`folder:\`.`
193
+ )
194
+ return []
195
+ }
196
196
  if (!pattern) {
197
197
  errors.push(`${RECORDS_YML_RELPATH}: ${where} is an empty string.`)
198
198
  return []
@@ -201,23 +201,23 @@ export function resolveFolder(entries, pool) {
201
201
  const hit = byRelPath.get(pattern)
202
202
  if (!hit) {
203
203
  errors.push(
204
- `${RECORDS_YML_RELPATH}: ${where} names "${pattern}", which is not in entities/. ` +
205
- `A bare string is a path under entities/, extension included.`
204
+ `${RECORDS_YML_RELPATH}: ${where} names "${pattern}", which is not in ${dir}/. ` +
205
+ `A path is relative to ${dir}/, extension included.`
206
206
  )
207
207
  return []
208
208
  }
209
209
  const leaf = place(hit, pathSegs, where)
210
210
  return leaf ? [leaf] : []
211
211
  }
212
- // ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `entities/artcle/*.md` is the
213
- // old empty-branch defect respelled, and it produced a real, reachable,
214
- // empty path with no warning.
212
+ // ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `artcle/*.md` is the old
213
+ // empty-branch defect respelled, and it produced a real, reachable, empty
214
+ // path with no warning.
215
215
  const matches = [...byRelPath.entries()]
216
216
  .filter(([rel]) => matchEntityPattern(pattern, rel))
217
217
  .map(([, e]) => e)
218
218
  if (matches.length === 0) {
219
219
  errors.push(
220
- `${RECORDS_YML_RELPATH}: ${where} pattern "${pattern}" matches no entity. ` +
220
+ `${RECORDS_YML_RELPATH}: ${where} pattern "${pattern}" matches no record in ${dir}/. ` +
221
221
  `Check the schema folder name and the extension.`
222
222
  )
223
223
  return []
@@ -230,7 +230,9 @@ export function resolveFolder(entries, pool) {
230
230
  }
231
231
 
232
232
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
233
- errors.push(`${RECORDS_YML_RELPATH}: ${where} is neither a path nor a typed entry.`)
233
+ errors.push(
234
+ `${RECORDS_YML_RELPATH}: ${where} is neither ${pathSegs.length ? 'a path nor ' : ''}a \`folder:\` entry.`
235
+ )
234
236
  return []
235
237
  }
236
238
 
@@ -247,11 +249,12 @@ export function resolveFolder(entries, pool) {
247
249
  errors.push(`${RECORDS_YML_RELPATH}: ${where} declares a folder with no name.`)
248
250
  return []
249
251
  }
250
- // ⭐ `name` is the handle (the URL segment, sibling-unique); `label` is the
252
+ // ⭐ A folder's `name` is the segment a query's `scope:` names; `label` is its
251
253
  // display text. The store renamed the pair on 2026-09-04 — `path_segment` →
252
- // `name`, and the old `name` (display) → `label` — so one word means one
253
- // thing from records.yml (`folder:` / `label:`) to the wire to the service's
254
- // `$name`.
254
+ // `name`, and the old `name` (display) → `label`. ⛔ Not a URL segment: a folder
255
+ // is the curator's organization and maps to no route unless a query binds
256
+ // `scope: :dir` [Diego]. *("the URL segment, sibling-unique" stood here until
257
+ // 2026-09-21.)*
255
258
  const branch = { kind: 'branch', name: segment }
256
259
  if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
257
260
  const kids = Array.isArray(entry.records) ? entry.records : []
@@ -283,23 +286,28 @@ export function resolveFolder(entries, pool) {
283
286
 
284
287
  errors.push(
285
288
  `${RECORDS_YML_RELPATH}: ${where} has no recognized kind. ` +
286
- `A bare string is an entity path; anything else says \`folder:\`, \`url:\` or \`asset:\`.`
289
+ (pathSegs.length
290
+ ? `Inside a folder, a path names records; anything else says \`folder:\`.`
291
+ : `An entry is a \`folder:\` — its \`records:\` are paths under ${dir}/.`)
287
292
  )
288
293
  return []
289
294
  }
290
295
 
291
- const nodes = (entries || []).flatMap((entry, i) => resolveEntry(entry, [], i, ''))
296
+ const branches = (entries || []).flatMap((entry, i) => resolveEntry(entry, [], i, ''))
292
297
 
293
- // ⚠️ REPORT WHAT NOTHING REFERENCES. It is a legitimate draft state, so it is
294
- // not an error — but silence is how every defect in this area survived.
295
- for (const e of pool || []) {
296
- if (!placements.has(e.id)) {
297
- warnings.push(
298
- `${e.relPath} is in entities/ but referenced by nothing in ${RECORDS_YML_RELPATH} — ` +
299
- `it exists, but it is not a record and no query can reach it.`
300
- )
301
- }
302
- }
298
+ // ⭐ EVERY RECORD NO FOLDER NAMES SITS AT THE TOP — placement in the directory is
299
+ // what made it a record, so there is nothing for it to be left out of. In the
300
+ // directory's order, numeric-aware within a schema folder, the same order a
301
+ // pattern's matches take.
302
+ const unplaced = (pool || []).filter((e) => !placements.has(e.id))
303
+ const dirKey = (e) => (e.dirs || []).join('/')
304
+ unplaced.sort((a, b) => {
305
+ const da = dirKey(a)
306
+ const db = dirKey(b)
307
+ if (da !== db) return da < db ? -1 : 1
308
+ return compareByNumericPrefix(a.slug, b.slug)
309
+ })
310
+ const top = unplaced.map((e) => place(e, [], 'the directory')).filter(Boolean)
303
311
 
304
- return { nodes, placements, errors, warnings }
312
+ return { nodes: [...branches, ...top], placements, errors, warnings }
305
313
  }
@@ -31,7 +31,8 @@ import { createHash } from 'node:crypto'
31
31
  import { ASSET_SLOTS } from '@uniweb/semantic-parser'
32
32
 
33
33
  /**
34
- * The fingerprint `assets.json` keeps of the URL a host serves an asset at.
34
+ * The fingerprint the asset map in `sync.json` keeps of the URL a host serves an
35
+ * asset at.
35
36
  *
36
37
  * A hash, never the URL: it can recognize an address a pull brings back and it
37
38
  * cannot be used to compose one — see the header. Prefixed so a reader of the
@@ -346,8 +346,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
346
346
  warnings.push(`finalized index ${i} has no matching submitted entity`)
347
347
  continue
348
348
  }
349
- // Skip the non-record entities: the site-content entity (its uuid is back-filled
350
- // into site.yml by the caller, via writeSiteEntityUuid) and the folder entity
349
+ // Skip the non-record entities: the site-content entity (its uuid is recorded in
350
+ // sync.json by the caller, via writeSiteEntityUuid) and the folder entity
351
351
  // (no uuid to back-fill — the backend owns the site's folder, keyed by the
352
352
  // site-content uuid). Both are positional placeholders with no record source file.
353
353
  if (entry.kind === 'site' || entry.kind === 'folder') continue
@@ -10,9 +10,9 @@
10
10
  // ⭐ THE FLAT-VS-RECURSIVE DIVERGENCE THIS FILE USED TO WARN ABOUT IS CLOSED.
11
11
  // It reported records below a collection's top level as building and rendering
12
12
  // locally while being absent from the synced set — the delivery lane recursed,
13
- // this one was one level deep. `entities/{schema}/` has no nesting to disagree
13
+ // this one was one level deep. `records/{schema}/` has no nesting to disagree
14
14
  // about (`site/entity-pool.js` refuses it), and placement moved to `records.yml`,
15
- // so both lanes now read the same flat pool. The warning went with the ambiguity.
15
+ // so both lanes now read the same flat directory. The warning went with the ambiguity.
16
16
  //
17
17
  // A collection `.md` is NOT a page-section `.md`: its frontmatter is structured
18
18
  // DATA whose schema is the collection type's data schema (the `model:` Model), and
@@ -55,7 +55,7 @@ function formatFor(ext) {
55
55
  /**
56
56
  * Read ONE entity file into its raw source record(s).
57
57
  *
58
- * ⭐ THE FILE IS THE UNIT NOW. `entities/{schema}/` supplies the model, so
58
+ * ⭐ THE FILE IS THE UNIT NOW. `records/{schema}/` supplies the model, so
59
59
  * nothing above this needs a directory scan to know what a file is — the pool
60
60
  * reader (`site/entity-pool.js`) already walked the tree and paired each file
61
61
  * with its schema. A single-record file yields one record; array-form YAML/JSON
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 = {