@uniweb/build 0.57.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.
- package/package.json +8 -8
- package/src/foundation-name.js +63 -0
- package/src/i18n/records.js +1 -1
- package/src/index.js +8 -0
- package/src/prerender.js +1 -1
- package/src/schema.js +67 -10
- package/src/site/content-collector.js +1 -1
- package/src/site/entity-pool.js +58 -10
- package/src/site/plugin.js +9 -7
- package/src/site/queries-config.js +2 -2
- package/src/site/query-processor.js +8 -7
- package/src/site/records-config.js +73 -32
- package/src/utils/numeric-prefix.js +1 -1
- package/src/uwx/backfill.js +22 -2
- package/src/uwx/entity-source.js +1 -1
- package/src/uwx/folder.js +3 -3
- package/src/uwx/index.js +4 -3
- package/src/uwx/project-writer.js +13 -11
- package/src/uwx/records-project.js +27 -25
- package/src/uwx/records.js +35 -39
- package/src/uwx/registry-package.js +8 -1
- package/src/uwx/sync-package.js +2 -2
|
@@ -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 `
|
|
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 {
|
|
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
|
|
51
|
+
export { FOLDER_YML }
|
|
44
52
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
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 `
|
|
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 `
|
|
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
|
-
* @
|
|
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 =
|
|
101
|
-
if (!existsSync(
|
|
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(
|
|
127
|
+
doc = yaml.load(await readFile(abs, 'utf8'), YAML_OPTIONS)
|
|
106
128
|
} catch (err) {
|
|
107
|
-
return { exists: true, entries: [], error: `${
|
|
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
|
-
`${
|
|
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 `
|
|
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 `
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
191
|
-
`${dir}/ is a record already — ${
|
|
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(`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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(`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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
|
-
`${
|
|
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.
|
package/src/uwx/backfill.js
CHANGED
|
@@ -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
|
}
|
package/src/uwx/entity-source.js
CHANGED
|
@@ -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
|
|
34
|
-
// sits at the top of the folder unless `
|
|
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: `
|
|
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
|
@@ -61,7 +61,7 @@ export { buildFolderEntity,
|
|
|
61
61
|
export {
|
|
62
62
|
recordsToProject,
|
|
63
63
|
declarationsToQueriesYml,
|
|
64
|
-
|
|
64
|
+
folderToFolderYml,
|
|
65
65
|
findRecordFileByUuid,
|
|
66
66
|
} from './records-project.js'
|
|
67
67
|
export {
|
|
@@ -127,12 +127,13 @@ export {
|
|
|
127
127
|
RECORDS_DIR,
|
|
128
128
|
} from '../site/entity-pool.js'
|
|
129
129
|
|
|
130
|
-
// How the folder is organized, so a verb can ask what `records.yml` SAYS without
|
|
130
|
+
// How the folder is organized, so a verb can ask what `records/folder.yml` SAYS without
|
|
131
131
|
// re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
|
|
132
132
|
// `site/records-config.js` does not reach a caller until it is named here.
|
|
133
133
|
export {
|
|
134
134
|
readRecordsConfig,
|
|
135
|
-
|
|
135
|
+
FOLDER_YML,
|
|
136
|
+
folderYmlPath,
|
|
136
137
|
} from '../site/records-config.js'
|
|
137
138
|
export {
|
|
138
139
|
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 {
|
|
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 `
|
|
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.
|
|
301
|
-
* sides, nothing to invert, so a pull is a mirror rather than an update.
|
|
302
|
-
* also nothing a shallow merge could mean here — the file is a sequence,
|
|
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
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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(
|
|
315
|
+
return writeYamlFile(folderYmlPath(siteRoot).abs, entries)
|
|
314
316
|
}
|
|
315
317
|
|
|
316
318
|
/**
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Records projection — write a pulled folder and its records back to the site's
|
|
2
|
-
// `records/**` files and `records.yml`. The
|
|
3
|
-
// + `folder.js`): the producer reads the
|
|
4
|
-
// `@uniweb/folder` entity + one section-keyed
|
|
5
|
-
// those documents back and renders them to files.
|
|
2
|
+
// `records/**` files and the folder's organization, `records/folder.yml`. The
|
|
3
|
+
// inverse of the producer (`records.js` + `folder.js`): the producer reads the
|
|
4
|
+
// records directory and emits the `@uniweb/folder` entity + one section-keyed
|
|
5
|
+
// `$`-document per record; this takes those documents back and renders them to files.
|
|
6
6
|
//
|
|
7
7
|
// Identity & placement:
|
|
8
8
|
// - a record's slug comes from the FOLDER document — each ref leaf is
|
|
@@ -38,7 +38,7 @@ import { parseFrontmatter } from './entity-source.js'
|
|
|
38
38
|
import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
|
|
39
39
|
import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
|
|
40
40
|
import { poolDirsForSchema, resolveRecordsDir } from '../site/entity-pool.js'
|
|
41
|
-
import {
|
|
41
|
+
import { folderYmlPath } from '../site/records-config.js'
|
|
42
42
|
import { isContentBodyField } from './data-schema.js'
|
|
43
43
|
import { unresolveSelfScope } from './self-scope.js'
|
|
44
44
|
import { unwrapLocalized } from './backfill.js'
|
|
@@ -370,18 +370,19 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
|
|
|
370
370
|
}
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
|
-
* Project a pulled `@uniweb/folder` document back to `
|
|
373
|
+
* Project a pulled `@uniweb/folder` document back to `folder.yml` in the records
|
|
374
|
+
* directory (`records/folder.yml`, or under `site.yml::paths.records`).
|
|
374
375
|
*
|
|
375
376
|
* ⭐ THE FOLDER ROUND-TRIPS TRIVIALLY, and that is by design rather than luck:
|
|
376
|
-
* `
|
|
377
|
+
* `folder.yml` holds concrete paths on both sides, so there is nothing to invert.
|
|
377
378
|
* The old shape put QUERY MACROS in the folder — a virtual `folders:` tree naming
|
|
378
379
|
* collections — and inverting a macro is not possible in general. Taking queries
|
|
379
380
|
* out of the folder is what dissolved that.
|
|
380
381
|
*
|
|
381
382
|
* ⭐ ONLY THE SUB-FOLDERS ARE WRITTEN (ruled 2026-09-21 [Diego]). A record at the
|
|
382
383
|
* top of the folder needs no line — being in the records directory is what puts it
|
|
383
|
-
* there — so `
|
|
384
|
-
* a folder with no branches is a site with no `
|
|
384
|
+
* there — so `folder.yml` carries the branches and the records placed in them, and
|
|
385
|
+
* a folder with no branches is a site with no `folder.yml`: a local one is REMOVED
|
|
385
386
|
* (it could only describe sub-folders the backend's folder no longer has). No state
|
|
386
387
|
* of the file removes a record, so this cannot empty anything.
|
|
387
388
|
*
|
|
@@ -395,11 +396,13 @@ export function declarationsToQueriesYml({ document, siteRoot, org, backend = nu
|
|
|
395
396
|
* under `records/` of the file just written for it. Supplied by
|
|
396
397
|
* `recordsToProject`, which is the only thing that knows the extension
|
|
397
398
|
* each record landed with.
|
|
398
|
-
* @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[] }}
|
|
399
|
+
* @returns {{ status: 'updated'|'unchanged'|'removed'|'skipped', entries: Array, warnings: string[], file: string }}
|
|
400
|
+
* `file` is where the organization lives, as the author would write it
|
|
399
401
|
*/
|
|
400
|
-
export function
|
|
402
|
+
export function folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
|
|
401
403
|
const warnings = []
|
|
402
|
-
|
|
404
|
+
const { abs, rel: file } = folderYmlPath(siteRoot)
|
|
405
|
+
if (!folderDoc) return { status: 'skipped', entries: [], warnings, file }
|
|
403
406
|
let unplaceable = false
|
|
404
407
|
|
|
405
408
|
const walk = (nodes, inBranch) => {
|
|
@@ -410,7 +413,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
410
413
|
const entry = { folder: node.name }
|
|
411
414
|
// Only a BRANCH takes a label. A record carries its own title; the folder
|
|
412
415
|
// does not caption its rows. On the wire the label is a localized map;
|
|
413
|
-
//
|
|
416
|
+
// folder.yml carries the source-locale string (a bare string passes).
|
|
414
417
|
if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
|
|
415
418
|
entry.records = walk(node.$children, true)
|
|
416
419
|
out.push(entry)
|
|
@@ -435,7 +438,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
435
438
|
// means the folder and the directory disagree, and writing the file without
|
|
436
439
|
// it would move that record to the top of the folder on the next push.
|
|
437
440
|
warnings.push(
|
|
438
|
-
|
|
441
|
+
`${file}: the folder places a record ("${node.name ?? '?'}") that was not ` +
|
|
439
442
|
`written locally — the file was left unchanged rather than dropping it.`
|
|
440
443
|
)
|
|
441
444
|
unplaceable = true
|
|
@@ -447,14 +450,13 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, source
|
|
|
447
450
|
}
|
|
448
451
|
|
|
449
452
|
const entries = walk(folderDoc.contents, false)
|
|
450
|
-
if (unplaceable) return { status: 'skipped', entries: [], warnings }
|
|
453
|
+
if (unplaceable) return { status: 'skipped', entries: [], warnings, file }
|
|
451
454
|
if (entries.length === 0) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
return { status: 'removed', entries, warnings }
|
|
455
|
+
if (!existsSync(abs)) return { status: 'unchanged', entries, warnings, file }
|
|
456
|
+
unlinkSync(abs)
|
|
457
|
+
return { status: 'removed', entries, warnings, file }
|
|
456
458
|
}
|
|
457
|
-
return { status: writeRecordsConfig(siteRoot, entries), entries, warnings }
|
|
459
|
+
return { status: writeRecordsConfig(siteRoot, entries), entries, warnings, file }
|
|
458
460
|
}
|
|
459
461
|
|
|
460
462
|
/**
|
|
@@ -506,7 +508,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
506
508
|
// own id → their uuid, for every record this pull wrote. Recorded at the end.
|
|
507
509
|
const learned = {}
|
|
508
510
|
// uuid → the path under `records/` the record landed at. Only this loop knows
|
|
509
|
-
// the extension each one got, so `
|
|
511
|
+
// the extension each one got, so `folder.yml` is written from it rather than
|
|
510
512
|
// re-derived (a second rule could pick a different extension and the folder
|
|
511
513
|
// would name a file that is not there).
|
|
512
514
|
const poolPathByUuid = new Map()
|
|
@@ -571,20 +573,20 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
571
573
|
else if (isNew) placed.push(filePath)
|
|
572
574
|
else updated.push(filePath)
|
|
573
575
|
// Keyed by THEIR uuid: the folder document references records in the backend's
|
|
574
|
-
// terms, so that is what `
|
|
576
|
+
// terms, so that is what `folderToFolderYml` will look them up by.
|
|
575
577
|
if (theirs) {
|
|
576
578
|
poolPathByUuid.set(theirs, relative(recordsRoot, filePath).split(sep).join('/'))
|
|
577
579
|
if (own) learned[own] = theirs
|
|
578
580
|
}
|
|
579
581
|
}
|
|
580
582
|
|
|
581
|
-
// ⭐ THE FOLDER ITSELF, written back as `
|
|
583
|
+
// ⭐ THE FOLDER ITSELF, written back as `folder.yml`. Steps that only touched the
|
|
582
584
|
// READ path would leave every pull authoring the old shape — the site would build
|
|
583
585
|
// from the new layout and be projected back into the one it replaced.
|
|
584
586
|
//
|
|
585
587
|
// The folder ENTITY still carries no `$uuid` we persist: the backend owns the
|
|
586
588
|
// site's folder, keyed by the site-content uuid.
|
|
587
|
-
const records =
|
|
589
|
+
const records = folderToFolderYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
|
|
588
590
|
warnings.push(...records.warnings)
|
|
589
591
|
|
|
590
592
|
// Flush localized record-field translations to locales/records/{locale}.json,
|
|
@@ -596,5 +598,5 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
|
|
|
596
598
|
updateBackendMap(siteRoot, opts.backend, 'records', learned)
|
|
597
599
|
}
|
|
598
600
|
|
|
599
|
-
return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status }
|
|
601
|
+
return { updated, placed, unchanged, skipped, warnings, locales, freeform, records: records.status, recordsFile: records.file }
|
|
600
602
|
}
|