@uniweb/build 0.57.0 → 0.59.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,9 +1,17 @@
1
- // `records.yml` — how the site's records folder is ORGANIZED. Optional.
1
+ // `records/folder.yml` — how the site's records folder is ORGANIZED. Optional.
2
2
  //
3
3
  // ⭐ IT DOES NOT SAY WHAT IS IN THE FOLDER (ruled 2026-09-21 [Diego]). Every file in
4
4
  // `records/` is a record — placing it there is what makes one (`entity-pool.js`).
5
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.
6
+ // flat set has no `folder.yml` at all, which is the common case.
7
+ //
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.
7
15
  //
8
16
  // ⛔ UNTIL 2026-09-21 THIS FILE WAS THE MEMBERSHIP LIST: a bare string at the top
9
17
  // level listed an entity and made it a record, an unlisted one was left out, and
@@ -34,21 +42,30 @@
34
42
 
35
43
  import { existsSync } from 'node:fs'
36
44
  import { readFile } from 'node:fs/promises'
37
- import { join } from 'node:path'
45
+ import { resolve } from 'node:path'
38
46
  import yaml from 'js-yaml'
39
47
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
40
48
  import { compareByNumericPrefix } from '../utils/numeric-prefix.js'
41
- import { RECORDS_DIR } from './entity-pool.js'
49
+ import { RECORDS_DIR, FOLDER_YML, resolveRecordsDir } from './entity-pool.js'
42
50
 
43
- export const RECORDS_YML_RELPATH = 'records.yml'
51
+ export { FOLDER_YML }
44
52
 
45
- /** Path to the records.yml file (whether or not it exists yet). */
46
- export function recordsYmlPath(siteRoot) {
47
- 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}` }
48
65
  }
49
66
 
50
67
  /**
51
- * Match one record path against a `records.yml` pattern.
68
+ * Match one record path against a `folder.yml` pattern.
52
69
  *
53
70
  * ⛔ `*` DOES NOT CROSS A `/`, which is what a reader expects of a file pattern
54
71
  * and is NOT what `@uniweb/core`'s `globMatch` does. That one backs the `like`
@@ -85,7 +102,7 @@ export function slugForEntity(entity) {
85
102
  }
86
103
 
87
104
  /**
88
- * Read `records.yml` — the folder's organization.
105
+ * Read `folder.yml` — the folder's organization, in the records directory.
89
106
  *
90
107
  * ⭐ MISSING AND EMPTY MEAN THE SAME: no sub-folders, every record at the top.
91
108
  * ⛔ Until 2026-09-21 they differed — missing left a backend's folder alone, empty
@@ -94,34 +111,40 @@ export function slugForEntity(entity) {
94
111
  *
95
112
  * ⛔ A MALFORMED FILE IS STILL AN ERROR: what it meant to organize cannot be guessed.
96
113
  *
97
- * @returns {Promise<{ exists: boolean, 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
98
120
  */
99
- export async function readRecordsConfig(siteRoot) {
100
- const file = join(siteRoot, RECORDS_YML_RELPATH)
101
- if (!existsSync(file)) return { exists: false, 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 }
102
124
 
103
125
  let doc
104
126
  try {
105
- doc = yaml.load(await readFile(file, 'utf8'), YAML_OPTIONS)
127
+ doc = yaml.load(await readFile(abs, 'utf8'), YAML_OPTIONS)
106
128
  } catch (err) {
107
- return { exists: true, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
129
+ return { exists: true, entries: [], error: `${file}: ${err.message}`, file }
108
130
  }
109
131
 
110
- if (doc === null || doc === undefined) return { exists: true, entries: [], error: null }
132
+ if (doc === null || doc === undefined) return { exists: true, entries: [], error: null, file }
111
133
  if (!Array.isArray(doc)) {
112
134
  return {
113
135
  exists: true,
114
136
  entries: [],
115
137
  error:
116
- `${RECORDS_YML_RELPATH} must be a LIST of folders, not ${typeof doc === 'object' ? 'a mapping' : 'a single value'}. ` +
138
+ `${file} must be a LIST of folders, not ${typeof doc === 'object' ? 'a mapping' : 'a single value'}. ` +
117
139
  `For example:\n - folder: archive\n records:\n - publication/2025-*.md`,
140
+ file,
118
141
  }
119
142
  }
120
- return { exists: true, entries: doc, error: null }
143
+ return { exists: true, entries: doc, error: null, file }
121
144
  }
122
145
 
123
146
  /**
124
- * Place the site's records in its folder: the sub-folders `records.yml` declares,
147
+ * Place the site's records in its folder: the sub-folders `folder.yml` declares,
125
148
  * and the top of the folder for every record no sub-folder names.
126
149
  *
127
150
  * Every rule here is a guard, and each one exists because its failure was
@@ -136,7 +159,7 @@ export async function readRecordsConfig(siteRoot) {
136
159
  * evaluates `scope` natively, so widening it is a cross-lane change to agree first,
137
160
  * not to infer. Until then the file lane is the floor: one folder.
138
161
  *
139
- * @param {Array} entries - the parsed `records.yml` list
162
+ * @param {Array} entries - the parsed `folder.yml` list
140
163
  * @param {Array} pool - records from `readEntityPool`
141
164
  * @param {object} [opts]
142
165
  * @param {string} [opts.dir] - the records directory as written, for messages
@@ -146,11 +169,15 @@ export async function readRecordsConfig(siteRoot) {
146
169
  * `{ entity, path, slug }`, `path` being `''` at the top of the folder.
147
170
  */
148
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}`
149
174
  const errors = []
150
175
  const warnings = []
151
176
  const placements = new Map()
152
177
  // Which entry claimed a file, so the second one can name the first.
153
178
  const claimedBy = new Map()
179
+ // Which entry declared a folder, by its path, for the same reason.
180
+ const folderAt = new Map()
154
181
 
155
182
  const byRelPath = new Map()
156
183
  for (const e of pool || []) {
@@ -165,7 +192,7 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
165
192
  const prior = claimedBy.get(key)
166
193
  if (prior) {
167
194
  errors.push(
168
- `${RECORDS_YML_RELPATH}: "${entity.relPath}" is placed twice — by ${prior} and by ${where}. ` +
195
+ `${file}: "${entity.relPath}" is placed twice — by ${prior} and by ${where}. ` +
169
196
  `A record sits in one folder; a computed subset is a named query, not a second placement.`
170
197
  )
171
198
  return null
@@ -187,21 +214,21 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
187
214
  // would claim to choose while choosing nothing: refused, with the reason.
188
215
  if (pathSegs.length === 0) {
189
216
  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. ` +
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. ` +
192
219
  `Remove the entry, or put it under a \`folder:\`.`
193
220
  )
194
221
  return []
195
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 ${dir}/. ` +
231
+ `${file}: ${where} names "${pattern}", which is not in ${dir}/. ` +
205
232
  `A path is relative to ${dir}/, extension included.`
206
233
  )
207
234
  return []
@@ -217,7 +244,7 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
217
244
  .map(([, e]) => e)
218
245
  if (matches.length === 0) {
219
246
  errors.push(
220
- `${RECORDS_YML_RELPATH}: ${where} pattern "${pattern}" matches no record in ${dir}/. ` +
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 []
@@ -231,7 +258,7 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
231
258
 
232
259
  if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
233
260
  errors.push(
234
- `${RECORDS_YML_RELPATH}: ${where} is neither ${pathSegs.length ? 'a path nor ' : ''}a \`folder:\` entry.`
261
+ `${file}: ${where} is neither ${pathSegs.length ? 'a path nor ' : ''}a \`folder:\` entry.`
235
262
  )
236
263
  return []
237
264
  }
@@ -246,7 +273,7 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
246
273
  const raw = entry.folder
247
274
  const segment = raw === null || raw === undefined ? '' : String(raw).trim()
248
275
  if (!segment) {
249
- errors.push(`${RECORDS_YML_RELPATH}: ${where} declares a folder with no name.`)
276
+ errors.push(`${file}: ${where} declares a folder with no name.`)
250
277
  return []
251
278
  }
252
279
  // ⭐ A folder's `name` is the segment a query's `scope:` names; `label` is its
@@ -255,12 +282,26 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
255
282
  // is the curator's organization and maps to no route unless a query binds
256
283
  // `scope: :dir` [Diego]. *("the URL segment, sibling-unique" stood here until
257
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)
258
299
  const branch = { kind: 'branch', name: segment }
259
300
  if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
260
301
  const kids = Array.isArray(entry.records) ? entry.records : []
261
302
  if (kids.length === 0) {
262
303
  warnings.push(
263
- `${RECORDS_YML_RELPATH}: folder "${segment}" (${where}) holds no records. ` +
304
+ `${file}: folder "${segment}" (${where}) holds no records. ` +
264
305
  `A folder exists to be QUERIED — if no query needs the slice, do not make it.`
265
306
  )
266
307
  }
@@ -277,7 +318,7 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
277
318
  if (entry.url !== undefined || entry.asset !== undefined) {
278
319
  const kind = entry.url !== undefined ? 'url' : 'asset'
279
320
  errors.push(
280
- `${RECORDS_YML_RELPATH}: ${where} declares \`${kind}:\`, which the folder producer ` +
321
+ `${file}: ${where} declares \`${kind}:\`, which the folder producer ` +
281
322
  `does not emit yet. A folder holds urls and assets by design, but nothing would ` +
282
323
  `be sent for this entry — so it is refused rather than dropped.`
283
324
  )
@@ -285,7 +326,7 @@ export function resolveFolder(entries, pool, { dir = RECORDS_DIR } = {}) {
285
326
  }
286
327
 
287
328
  errors.push(
288
- `${RECORDS_YML_RELPATH}: ${where} has no recognized kind. ` +
329
+ `${file}: ${where} has no recognized kind. ` +
289
330
  (pathSegs.length
290
331
  ? `Inside a folder, a path names records; anything else says \`folder:\`.`
291
332
  : `An entry is a \`folder:\` — its \`records:\` are paths under ${dir}/.`)
@@ -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.
@@ -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
@@ -351,6 +361,15 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
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
  }
@@ -11,7 +11,7 @@
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
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.yml`,
14
+ // about (`site/entity-pool.js` refuses it), and placement moved to `records/folder.yml`,
15
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
package/src/uwx/folder.js CHANGED
@@ -30,8 +30,8 @@
30
30
  // carry.
31
31
  //
32
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
33
+ // `records/folder.yml` (ruled 2026-09-21 [Diego]). Every file in `records/` is a
34
+ // record and sits at the top of the folder unless `folder.yml` places it in a sub-folder. The
35
35
  // organization used to be DERIVED — one branch per collection, mirroring the
36
36
  // `collections/` subfolders — and the difference is the point: a sub-folder is a
37
37
  // thing the author states, never a shadow of the schema folders, which declare a
@@ -209,7 +209,7 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
209
209
  * @param {object} params
210
210
  * @param {object[]} params.recordEntities - the record entities (full set, BEFORE
211
211
  * send-only-changed filtering), each `{ id, uuid, slug, model }`
212
- * @param {Array} params.folderNodes - the placed records: `records.yml`'s sub-folders,
212
+ * @param {Array} params.folderNodes - the placed records: `folder.yml`'s sub-folders,
213
213
  * then every record at the top
214
214
  * @param {boolean} [params.declared] - whether the records DIRECTORY exists. See below.
215
215
  * @param {Record<string,string>} [params.itemUuids] - path → `$uuid`, harvested
package/src/uwx/index.js CHANGED
@@ -43,7 +43,6 @@ export {
43
43
  siteProjectToDocument,
44
44
  emitSiteSyncPackage,
45
45
  writeSiteEntityUuid,
46
- writeSiteOrg,
47
46
  extensionDeclaration,
48
47
  isExtensionUrl,
49
48
  isSiteRelativeExtensionUrl,
@@ -61,9 +60,12 @@ export { buildFolderEntity,
61
60
  export {
62
61
  recordsToProject,
63
62
  declarationsToQueriesYml,
64
- folderToRecordsYml,
63
+ folderToFolderYml,
65
64
  findRecordFileByUuid,
66
65
  } from './records-project.js'
66
+ // The scope a site's `@/x` refs resolve into — its foundation's. A pull resolves it
67
+ // up front and hands it to the synchronous projections above.
68
+ export { siteSelfScope } from './self-scope.js'
67
69
  export {
68
70
  siteInfoToConfig,
69
71
  sectionRecordToFile,
@@ -127,12 +129,13 @@ export {
127
129
  RECORDS_DIR,
128
130
  } from '../site/entity-pool.js'
129
131
 
130
- // How the folder is organized, so a verb can ask what `records.yml` SAYS without
132
+ // How the folder is organized, so a verb can ask what `records/folder.yml` SAYS without
131
133
  // re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
132
134
  // `site/records-config.js` does not reach a caller until it is named here.
133
135
  export {
134
136
  readRecordsConfig,
135
- RECORDS_YML_RELPATH,
137
+ FOLDER_YML,
138
+ folderYmlPath,
136
139
  } from '../site/records-config.js'
137
140
  export {
138
141
  findRecordFile,
@@ -19,7 +19,7 @@ import { proseMirrorToMarkdown, serializeFrontmatter } from '@uniweb/content-wri
19
19
  import { parseFrontmatter } from './entity-source.js'
20
20
  import { renderEntityDocument } from './backfill.js'
21
21
  import { queriesYmlPath } from './queries-config.js'
22
- import { recordsYmlPath } from '../site/records-config.js'
22
+ import { folderYmlPath } from '../site/records-config.js'
23
23
  import { DECLARATION_KEYS } from '../site/fetch-shapes.js'
24
24
 
25
25
  // Frontmatter keys that belong to the CCA framework / the developer's local
@@ -294,23 +294,25 @@ export function writeQueriesConfig(siteRoot, queries) {
294
294
  }
295
295
 
296
296
  /**
297
- * Write `records.yml` — the site's folder, as a LIST.
297
+ * Write the records folder's organization — `folder.yml` in the records directory
298
+ * (`records/`, or wherever `site.yml::paths.records` puts it), as a LIST.
298
299
  *
299
300
  * ⛔ A FULL WRITE, NOT A MERGE, and that is the one place this differs from every
300
- * other projected config. `records.yml` IS the folder: concrete refs on both
301
- * sides, nothing to invert, so a pull is a mirror rather than an update. There is
302
- * also nothing a shallow merge could mean here — the file is a sequence, and
303
- * merging two lists either duplicates entries or silently drops them.
301
+ * other projected config. The file mirrors the folder's sub-folders: concrete refs
302
+ * on both sides, nothing to invert, so a pull is a mirror rather than an update.
303
+ * There is also nothing a shallow merge could mean here — the file is a sequence,
304
+ * and merging two lists either duplicates entries or silently drops them.
304
305
  *
305
- * ⚠️ WHICH IS WHY AN EMPTY LIST IS NOT WRITTEN AS A FILE-WITH-NOTHING BY ACCIDENT.
306
- * An empty `records.yml` is DESTRUCTIVE on the next push — it says the folder holds
307
- * nothing. A pull that carried no folder must leave the file alone, so the caller
308
- * decides, and this only writes what it was actually given.
306
+ * The caller decides whether there is anything to write: a folder with no
307
+ * sub-folders is no file at all (`folderToFolderYml` removes it), since missing and
308
+ * empty mean the same. ⚠️ *Until 2026-09-21 this said an empty file was destructive
309
+ * on the next push; that stopped being true when the directory, not this file,
310
+ * became what decides which records the folder holds.*
309
311
  *
310
312
  * @returns {'updated'|'unchanged'}
311
313
  */
312
314
  export function writeRecordsConfig(siteRoot, entries) {
313
- return writeYamlFile(recordsYmlPath(siteRoot), entries)
315
+ return writeYamlFile(folderYmlPath(siteRoot).abs, entries)
314
316
  }
315
317
 
316
318
  /**