@uniweb/build 0.56.0 → 0.58.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,70 +1,71 @@
1
- // `records.yml` — the site's FOLDER: what is in it, and where.
1
+ // `records/folder.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 `folder.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:
8
+ // ⭐ IT LIVES IN THE DIRECTORY IT ORGANIZES (ruled 2026-09-21 [Diego]) — the one file
9
+ // at the top of `records/` that is not a record. The directory is the site's folder
10
+ // on the file side, and this is that folder's organization: its paths are relative
11
+ // to the directory, it moves with `paths.records`, and it travels with a records
12
+ // directory that sites share — as a pages directory's own `folder.yml` does.
13
+ // ⛔ It was `records.yml` at the site root until 2026-09-21, and a file there is
14
+ // refused by name (`entity-pool.js::refuseRetiredFolderFile`), not read.
11
15
  //
12
- // - person/*.md
13
- // - publication/*.md
14
- // - project/*.md
16
+ // ⛔ UNTIL 2026-09-21 THIS FILE WAS THE MEMBERSHIP LIST: a bare string at the top
17
+ // level listed an entity and made it a record, an unlisted one was left out, and
18
+ // the file's absence or emptiness decided what a push did. All of that moved to
19
+ // the directory. A top-level path is refused rather than read — it would claim to
20
+ // select records while selecting nothing.
15
21
  //
16
22
  // ⛔ 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.
23
+ // dimension a query slices on — `scope: archive` — never a menu, a listing or a URL
24
+ // tree. The question is not "does this site want sub-pages?" but "will a query ever
25
+ // ask for a SLICE rather than the whole set?" Most will not, which is why flat is
26
+ // the norm rather than a simplification.
21
27
  //
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.)
28
+ // ⭐ THE SHAPE IS A LIST OF FOLDERS, each holding records by path — one file, or a
29
+ // pattern matching many — and further folders:
28
30
  //
29
- // ⛔ NO QUERIES IN HERE. A computed subset is a named query, in `queries.yml`.
30
- // Curation enumerates; queries compute. Two constructs, two jobs.
31
- //
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:
31
+ // - folder: archive
32
+ // label: Publication Archive
33
+ // records:
34
+ // - publication/2025-*.md
34
35
  //
35
- // missing → inert. Do not sync; leave the server's folder untouched.
36
- // empty → destructive. Sync an empty folder, removing what is there.
36
+ // Every record no folder names sits at the top of the site's folder.
37
37
  //
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.
38
+ // ⛔ NO QUERIES IN HERE. A computed subset is a named query, in `queries.yml`.
39
+ // Organizing places records; queries compute. Two constructs, two jobs.
44
40
  //
45
- // Model: entity · record · query · folder.
41
+ // Model: record · folder · query.
46
42
 
47
43
  import { existsSync } from 'node:fs'
48
44
  import { readFile } from 'node:fs/promises'
49
- import { join } from 'node:path'
45
+ import { resolve } from 'node:path'
50
46
  import yaml from 'js-yaml'
51
47
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
52
48
  import { compareByNumericPrefix } from '../utils/numeric-prefix.js'
49
+ import { RECORDS_DIR, FOLDER_YML, resolveRecordsDir } from './entity-pool.js'
53
50
 
54
- export const RECORDS_YML_RELPATH = 'records.yml'
51
+ export { FOLDER_YML }
55
52
 
56
- /** Path to the records.yml file (whether or not it exists yet). */
57
- export function recordsYmlPath(siteRoot) {
58
- return join(siteRoot, RECORDS_YML_RELPATH)
53
+ /**
54
+ * Where the folder's organization lives — `folder.yml` in the records directory
55
+ * (`site.yml::paths.records`, else `records/`) — whether or not it exists yet.
56
+ *
57
+ * @param {string} siteRoot
58
+ * @param {string} [dir] - the records directory as written, when the caller has it;
59
+ * otherwise it is resolved here, which also refuses the retired layouts
60
+ * @returns {{ abs: string, rel: string }} `rel` for messages (`records/folder.yml`)
61
+ */
62
+ export function folderYmlPath(siteRoot, dir) {
63
+ const rel = dir ?? resolveRecordsDir(siteRoot).rel
64
+ return { abs: resolve(siteRoot, rel, FOLDER_YML), rel: `${rel}/${FOLDER_YML}` }
59
65
  }
60
66
 
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
67
  /**
67
- * Match one entity path against a `records.yml` pattern.
68
+ * Match one record path against a `folder.yml` pattern.
68
69
  *
69
70
  * ⛔ `*` DOES NOT CROSS A `/`, which is what a reader expects of a file pattern
70
71
  * and is NOT what `@uniweb/core`'s `globMatch` does. That one backs the `like`
@@ -101,73 +102,88 @@ export function slugForEntity(entity) {
101
102
  }
102
103
 
103
104
  /**
104
- * Read `records.yml`.
105
+ * Read `folder.yml` — the folder's organization, in the records directory.
106
+ *
107
+ * ⭐ MISSING AND EMPTY MEAN THE SAME: no sub-folders, every record at the top.
108
+ * ⛔ Until 2026-09-21 they differed — missing left a backend's folder alone, empty
109
+ * removed what was there — because this file decided what was in the folder. The
110
+ * `records/` directory decides that now, so no state of this file removes a record.
105
111
  *
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".
112
+ * ⛔ A MALFORMED FILE IS STILL AN ERROR: what it meant to organize cannot be guessed.
109
113
  *
110
- * @returns {Promise<{ state: string, entries: Array, error: string|null }>}
114
+ * @param {string} siteRoot
115
+ * @param {object} [opts]
116
+ * @param {string} [opts.dir] - the records directory as written (`readEntityPool`'s
117
+ * `dir`), when the caller has it; otherwise it is resolved from `site.yml`
118
+ * @returns {Promise<{ exists: boolean, entries: Array, error: string|null, file: string }>}
119
+ * `file` is where it was looked for, as the author would write it
111
120
  */
112
- export async function readRecordsConfig(siteRoot) {
113
- const file = join(siteRoot, RECORDS_YML_RELPATH)
114
- if (!existsSync(file)) return { state: FOLDER_MISSING, entries: [], error: null }
121
+ export async function readRecordsConfig(siteRoot, { dir } = {}) {
122
+ const { abs, rel: file } = folderYmlPath(siteRoot, dir)
123
+ if (!existsSync(abs)) return { exists: false, entries: [], error: null, file }
115
124
 
116
125
  let doc
117
126
  try {
118
- doc = yaml.load(await readFile(file, 'utf8'), YAML_OPTIONS)
127
+ doc = yaml.load(await readFile(abs, 'utf8'), YAML_OPTIONS)
119
128
  } catch (err) {
120
- return { state: FOLDER_MISSING, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
129
+ return { exists: true, entries: [], error: `${file}: ${err.message}`, file }
121
130
  }
122
131
 
123
- if (doc === null || doc === undefined || (Array.isArray(doc) && doc.length === 0)) {
124
- return { state: FOLDER_EMPTY, entries: [], error: null }
125
- }
132
+ if (doc === null || doc === undefined) return { exists: true, entries: [], error: null, file }
126
133
  if (!Array.isArray(doc)) {
127
134
  return {
128
- state: FOLDER_MISSING,
135
+ exists: true,
129
136
  entries: [],
130
137
  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`,
138
+ `${file} must be a LIST of folders, not ${typeof doc === 'object' ? 'a mapping' : 'a single value'}. ` +
139
+ `For example:\n - folder: archive\n records:\n - publication/2025-*.md`,
140
+ file,
133
141
  }
134
142
  }
135
- return { state: FOLDER_DECLARED, entries: doc, error: null }
143
+ return { exists: true, entries: doc, error: null, file }
136
144
  }
137
145
 
138
146
  /**
139
- * Resolve the folder's entries against the entity pool.
147
+ * Place the site's records in its folder: the sub-folders `folder.yml` declares,
148
+ * and the top of the folder for every record no sub-folder names.
140
149
  *
141
150
  * Every rule here is a guard, and each one exists because its failure was
142
151
  * SILENT. `entries: [artcles]` used to produce a real, reachable, empty path
143
152
  * with no warning at all.
144
153
  *
145
- * ⛔ ONE PLACEMENT PER ENTITY. Two entries matching one file is a hard error, not
154
+ * ⛔ ONE PLACEMENT PER RECORD. Two entries matching one file is a hard error, not
146
155
  * 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.
156
+ * a placement is banked by the record it references — but a record's `path` is one string:
157
+ * `@uniweb/core`'s `withinScope` matches nothing that is not a string, so a record
158
+ * with two paths would fall outside every `scope:`, silently. The records service
159
+ * evaluates `scope` natively, so widening it is a cross-lane change to agree first,
160
+ * not to infer. Until then the file lane is the floor: one folder.
152
161
  *
153
- * @param {Array} entries - the parsed `records.yml` list
154
- * @param {Array} pool - entities from `readEntityPool`
162
+ * @param {Array} entries - the parsed `folder.yml` list
163
+ * @param {Array} pool - records from `readEntityPool`
164
+ * @param {object} [opts]
165
+ * @param {string} [opts.dir] - the records directory as written, for messages
155
166
  * @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 }`.
167
+ * `nodes` is the folder tree — the declared sub-folders in file order, then every
168
+ * record no sub-folder names; `placements` maps EVERY record's id to its
169
+ * `{ entity, path, slug }`, `path` being `''` at the top of the folder.
158
170
  */
159
- export function resolveFolder(entries, pool) {
171
+ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
172
+ // The file as the author sees it, for every message below.
173
+ const file = `${dir}/${FOLDER_YML}`
160
174
  const errors = []
161
175
  const warnings = []
162
176
  const placements = new Map()
163
177
  // Which entry claimed a file, so the second one can name the first.
164
178
  const claimedBy = new Map()
179
+ // Which entry declared a folder, by its path, for the same reason.
180
+ const folderAt = new Map()
165
181
 
166
182
  const byRelPath = new Map()
167
183
  for (const e of pool || []) {
168
184
  // ⚠️ KEYED BY THE FILE AS IT EXISTS, prefix and all — an author writing
169
185
  // `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/`.
186
+ // produces. `poolPath` is that path, relative to `records/`.
171
187
  byRelPath.set(e.poolPath, e)
172
188
  }
173
189
 
@@ -176,8 +192,8 @@ export function resolveFolder(entries, pool) {
176
192
  const prior = claimedBy.get(key)
177
193
  if (prior) {
178
194
  errors.push(
179
- `${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.`
195
+ `${file}: "${entity.relPath}" is placed twice — by ${prior} and by ${where}. ` +
196
+ `A record sits in one folder; a computed subset is a named query, not a second placement.`
181
197
  )
182
198
  return null
183
199
  }
@@ -193,31 +209,42 @@ export function resolveFolder(entries, pool) {
193
209
 
194
210
  if (typeof entry === 'string') {
195
211
  const pattern = entry.trim()
212
+ // ⛔ A PATH AT THE TOP LEVEL SELECTED RECORDS until 2026-09-21 — it was how a
213
+ // file became one. Every file in the directory is a record now, so the line
214
+ // would claim to choose while choosing nothing: refused, with the reason.
215
+ if (pathSegs.length === 0) {
216
+ errors.push(
217
+ `${file}: ${where} ("${pattern}") lists records at the top level. Every file in ` +
218
+ `${dir}/ is a record already — ${file} only sorts records into folders. ` +
219
+ `Remove the entry, or put it under a \`folder:\`.`
220
+ )
221
+ return []
222
+ }
196
223
  if (!pattern) {
197
- errors.push(`${RECORDS_YML_RELPATH}: ${where} is an empty string.`)
224
+ errors.push(`${file}: ${where} is an empty string.`)
198
225
  return []
199
226
  }
200
227
  if (!isPattern(pattern)) {
201
228
  const hit = byRelPath.get(pattern)
202
229
  if (!hit) {
203
230
  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.`
231
+ `${file}: ${where} names "${pattern}", which is not in ${dir}/. ` +
232
+ `A path is relative to ${dir}/, extension included.`
206
233
  )
207
234
  return []
208
235
  }
209
236
  const leaf = place(hit, pathSegs, where)
210
237
  return leaf ? [leaf] : []
211
238
  }
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.
239
+ // ⛔ A PATTERN MATCHING NOTHING IS AN ERROR. `artcle/*.md` is the old
240
+ // empty-branch defect respelled, and it produced a real, reachable, empty
241
+ // path with no warning.
215
242
  const matches = [...byRelPath.entries()]
216
243
  .filter(([rel]) => matchEntityPattern(pattern, rel))
217
244
  .map(([, e]) => e)
218
245
  if (matches.length === 0) {
219
246
  errors.push(
220
- `${RECORDS_YML_RELPATH}: ${where} pattern "${pattern}" matches no entity. ` +
247
+ `${file}: ${where} pattern "${pattern}" matches no record in ${dir}/. ` +
221
248
  `Check the schema folder name and the extension.`
222
249
  )
223
250
  return []
@@ -230,7 +257,9 @@ export function resolveFolder(entries, pool) {
230
257
  }
231
258
 
232
259
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
233
- errors.push(`${RECORDS_YML_RELPATH}: ${where} is neither a path nor a typed entry.`)
260
+ errors.push(
261
+ `${file}: ${where} is neither ${pathSegs.length ? 'a path nor ' : ''}a \`folder:\` entry.`
262
+ )
234
263
  return []
235
264
  }
236
265
 
@@ -244,20 +273,35 @@ export function resolveFolder(entries, pool) {
244
273
  const raw = entry.folder
245
274
  const segment = raw === null || raw === undefined ? '' : String(raw).trim()
246
275
  if (!segment) {
247
- errors.push(`${RECORDS_YML_RELPATH}: ${where} declares a folder with no name.`)
276
+ errors.push(`${file}: ${where} declares a folder with no name.`)
248
277
  return []
249
278
  }
250
- // ⭐ `name` is the handle (the URL segment, sibling-unique); `label` is the
279
+ // ⭐ A folder's `name` is the segment a query's `scope:` names; `label` is its
251
280
  // 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`.
281
+ // `name`, and the old `name` (display) → `label`. ⛔ Not a URL segment: a folder
282
+ // is the curator's organization and maps to no route unless a query binds
283
+ // `scope: :dir` [Diego]. *("the URL segment, sibling-unique" stood here until
284
+ // 2026-09-21.)*
285
+ // ⛔ TWO FOLDERS AT ONE LEVEL CANNOT SHARE A NAME. A query's `scope:` names a
286
+ // folder by its path, so a second `archive` beside the first would make one scope
287
+ // of both, and a backend refuses a folder holding the pair. Records at one level
288
+ // may share a name; folders may not.
289
+ const at = [...pathSegs, segment].join('/')
290
+ const prior = folderAt.get(at)
291
+ if (prior) {
292
+ errors.push(
293
+ `${file}: folder "${segment}" is declared twice at one level — by ${prior} and by ${where}. ` +
294
+ `A query's \`scope:\` names a folder by its path, so merge them into one entry.`
295
+ )
296
+ return []
297
+ }
298
+ folderAt.set(at, where)
255
299
  const branch = { kind: 'branch', name: segment }
256
300
  if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
257
301
  const kids = Array.isArray(entry.records) ? entry.records : []
258
302
  if (kids.length === 0) {
259
303
  warnings.push(
260
- `${RECORDS_YML_RELPATH}: folder "${segment}" (${where}) holds no records. ` +
304
+ `${file}: folder "${segment}" (${where}) holds no records. ` +
261
305
  `A folder exists to be QUERIED — if no query needs the slice, do not make it.`
262
306
  )
263
307
  }
@@ -274,7 +318,7 @@ export function resolveFolder(entries, pool) {
274
318
  if (entry.url !== undefined || entry.asset !== undefined) {
275
319
  const kind = entry.url !== undefined ? 'url' : 'asset'
276
320
  errors.push(
277
- `${RECORDS_YML_RELPATH}: ${where} declares \`${kind}:\`, which the folder producer ` +
321
+ `${file}: ${where} declares \`${kind}:\`, which the folder producer ` +
278
322
  `does not emit yet. A folder holds urls and assets by design, but nothing would ` +
279
323
  `be sent for this entry — so it is refused rather than dropped.`
280
324
  )
@@ -282,24 +326,29 @@ export function resolveFolder(entries, pool) {
282
326
  }
283
327
 
284
328
  errors.push(
285
- `${RECORDS_YML_RELPATH}: ${where} has no recognized kind. ` +
286
- `A bare string is an entity path; anything else says \`folder:\`, \`url:\` or \`asset:\`.`
329
+ `${file}: ${where} has no recognized kind. ` +
330
+ (pathSegs.length
331
+ ? `Inside a folder, a path names records; anything else says \`folder:\`.`
332
+ : `An entry is a \`folder:\` — its \`records:\` are paths under ${dir}/.`)
287
333
  )
288
334
  return []
289
335
  }
290
336
 
291
- const nodes = (entries || []).flatMap((entry, i) => resolveEntry(entry, [], i, ''))
337
+ const branches = (entries || []).flatMap((entry, i) => resolveEntry(entry, [], i, ''))
292
338
 
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
- }
339
+ // ⭐ EVERY RECORD NO FOLDER NAMES SITS AT THE TOP — placement in the directory is
340
+ // what made it a record, so there is nothing for it to be left out of. In the
341
+ // directory's order, numeric-aware within a schema folder, the same order a
342
+ // pattern's matches take.
343
+ const unplaced = (pool || []).filter((e) => !placements.has(e.id))
344
+ const dirKey = (e) => (e.dirs || []).join('/')
345
+ unplaced.sort((a, b) => {
346
+ const da = dirKey(a)
347
+ const db = dirKey(b)
348
+ if (da !== db) return da < db ? -1 : 1
349
+ return compareByNumericPrefix(a.slug, b.slug)
350
+ })
351
+ const top = unplaced.map((e) => place(e, [], 'the directory')).filter(Boolean)
303
352
 
304
- return { nodes, placements, errors, warnings }
353
+ return { nodes: [...branches, ...top], placements, errors, warnings }
305
354
  }
@@ -1,6 +1,6 @@
1
1
  // The `01-` filename convention — ONE rule, for pages and for records.
2
2
  //
3
- // ⛔ IT WAS PRIVATE TO THE PAGES LANE, and `records.yml` needs exactly the same
3
+ // ⛔ IT WAS PRIVATE TO THE PAGES LANE, and `records/folder.yml` needs exactly the same
4
4
  // rule: a leading `01-` orders a file and is stripped from the name it produces.
5
5
  // Two copies would drift, and the drift would be invisible — a section ordering
6
6
  // one way and a record another, both plausible, with nothing comparing them.
@@ -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
@@ -228,6 +228,8 @@ function briefHasContentBody(declaration) {
228
228
  * entity: the entity `$uuid` + the brief section's fields (localized unwrapped,
229
229
  * date/scalars verbatim), with the brief record's own `$uuid` DROPPED (the backend
230
230
  * matches a single-section item by singularity) and `$model`/`$id`/`$meta` omitted.
231
+ * A disabled entity (`$disabled: true`) writes `draft: true`, and an enabled one writes
232
+ * no `draft:` at all.
231
233
  * For markdown, the content body field becomes the body; for YAML/JSON it stays a field.
232
234
  *
233
235
  * @param {object} params
@@ -283,6 +285,11 @@ export function renderEntityDocument({ document, declaration, format, sourceLoca
283
285
  }
284
286
  record[key] = value
285
287
  }
288
+ // ⭐ A DISABLED ENTITY IS A DRAFT ON THE FILE SIDE. It is kept in the folder and never
289
+ // publicly delivered, which is exactly what `draft: true` means. An enabled entity
290
+ // carries no key and writes no `draft:`, so a record re-enabled on the backend
291
+ // comes back without the line.
292
+ if (document.$disabled === true) record.draft = true
286
293
 
287
294
  if (format === 'json') return JSON.stringify(record, null, 2) + '\n'
288
295
  if (format === 'md') return `---\n${yaml.dump(record)}---\n${body}`
@@ -318,8 +325,9 @@ function writeIfChanged(filePath, text) {
318
325
  * order: `{ id, model, slug, sourceFile, format?, multiRecord?, declaration? }`.
319
326
  * @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
320
327
  * @param {string} [params.sourceLocale]
321
- * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string> }}
328
+ * @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string>, notKeptAsDrafts: string[] }}
322
329
  * `mapped` is own id → the uuid this backend minted, for the caller to record per backend.
330
+ * `notKeptAsDrafts` names records sent as drafts whose returned document is not disabled.
323
331
  */
324
332
  export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
325
333
  const updated = []
@@ -336,6 +344,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
336
344
  // Multi-record files are written ONCE per file, applying every (slug → uuid).
337
345
  const arrayFiles = new Map() // array-form YAML/JSON: sourceFile -> Map(slug -> uuid)
338
346
  const bibFiles = new Map() // BibTeX: sourceFile -> Map(cite key -> uuid)
347
+ // Records sent as drafts that the backend stored enabled. See the check below.
348
+ const notKeptAsDrafts = []
339
349
 
340
350
  for (const fin of finalized || []) {
341
351
  const uuid = fin.uuid
@@ -346,11 +356,20 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
346
356
  warnings.push(`finalized index ${i} has no matching submitted entity`)
347
357
  continue
348
358
  }
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
359
+ // Skip the non-record entities: the site-content entity (its uuid is recorded in
360
+ // sync.json by the caller, via writeSiteEntityUuid) and the folder entity
351
361
  // (no uuid to back-fill — the backend owns the site's folder, keyed by the
352
362
  // site-content uuid). Both are positional placeholders with no record source file.
353
363
  if (entry.kind === 'site' || entry.kind === 'folder') continue
364
+ // ⛔ A DRAFT THE BACKEND DID NOT KEEP AS ONE. A backend is obliged to echo
365
+ // `$disabled: true` on the document of an entity it stored disabled. One that predates
366
+ // the key skips it without a word and stores the record enabled, so it is delivered
367
+ // once the site is published. Rendering that document over the file (variant A) would
368
+ // then erase `draft: true` from the author's file as well, so the file keeps its own
369
+ // text and the caller is told. Only a returned document that lacks the flag counts:
370
+ // with no document there is nothing to judge.
371
+ const draftNotKept = entry.draft === true && fin.document && fin.document.$disabled !== true
372
+ if (draftNotKept) notKeptAsDrafts.push(entry.sourceFile || entry.id)
354
373
  if (!entry.sourceFile) {
355
374
  deferred.push({ index: i, id: entry.id, reason: 'no source file on disk' })
356
375
  continue
@@ -389,6 +408,7 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
389
408
  // the declaration, and it's lossless for the format (md needs a content body
390
409
  // field for its body). Otherwise variant B: back-fill the uuid in place.
391
410
  const canRenderA =
411
+ !draftNotKept &&
392
412
  fin.document &&
393
413
  entry.declaration &&
394
414
  (entry.format !== 'md' || briefHasContentBody(entry.declaration))
@@ -432,5 +452,5 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
432
452
  for (const file of ownIdFiles) {
433
453
  if (!updated.includes(file) && !unchanged.includes(file)) unchanged.push(file)
434
454
  }
435
- return { updated, unchanged, deferred, warnings, mapped }
455
+ return { updated, unchanged, deferred, warnings, mapped, notKeptAsDrafts }
436
456
  }
@@ -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
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.
13
+ // this one was one level deep. `records/{schema}/` has no nesting to disagree
14
+ // about (`site/entity-pool.js` refuses it), and placement moved to `records/folder.yml`,
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