@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.
- package/package.json +7 -7
- package/src/content/index.js +3 -0
- package/src/i18n/data-strings.js +1 -1
- package/src/i18n/freeform.js +20 -19
- package/src/i18n/index.js +4 -4
- package/src/i18n/records.js +9 -5
- package/src/site/build-site-data.js +4 -3
- package/src/site/config.js +1 -1
- package/src/site/content-collector.js +4 -3
- package/src/site/entity-pool.js +136 -42
- package/src/site/plugin.js +12 -9
- package/src/site/queries-config.js +15 -5
- package/src/site/query-processor.js +126 -112
- package/src/site/record-draft.js +40 -0
- package/src/site/records-config.js +101 -93
- package/src/uwx/asset-map.js +2 -1
- package/src/uwx/backfill.js +2 -2
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +9 -9
- package/src/uwx/records-project.js +95 -64
- package/src/uwx/records.js +310 -214
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +3 -3
- package/src/uwx/site.js +45 -39
- package/src/uwx/sync-package.js +19 -15
- package/src/validate-data.js +3 -1
|
@@ -1,48 +1,36 @@
|
|
|
1
|
-
// `records.yml` — the site's
|
|
1
|
+
// `records.yml` — how the site's records folder is ORGANIZED. Optional.
|
|
2
2
|
//
|
|
3
|
-
// ⭐
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
// ⛔
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
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 — `
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
//
|
|
30
|
-
//
|
|
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
|
-
//
|
|
33
|
-
//
|
|
23
|
+
// - folder: archive
|
|
24
|
+
// label: Publication Archive
|
|
25
|
+
// records:
|
|
26
|
+
// - publication/2025-*.md
|
|
34
27
|
//
|
|
35
|
-
//
|
|
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
|
-
//
|
|
39
|
-
//
|
|
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:
|
|
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
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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 {
|
|
107
|
+
return { exists: true, entries: [], error: `${RECORDS_YML_RELPATH}: ${err.message}` }
|
|
121
108
|
}
|
|
122
109
|
|
|
123
|
-
if (doc === null || doc === undefined
|
|
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
|
-
|
|
113
|
+
exists: true,
|
|
129
114
|
entries: [],
|
|
130
115
|
error:
|
|
131
|
-
`${RECORDS_YML_RELPATH} must be a LIST of
|
|
132
|
-
`
|
|
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 {
|
|
120
|
+
return { exists: true, entries: doc, error: null }
|
|
136
121
|
}
|
|
137
122
|
|
|
138
123
|
/**
|
|
139
|
-
*
|
|
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
|
|
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
|
-
*
|
|
148
|
-
* `
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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 -
|
|
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
|
|
157
|
-
* `placements` maps
|
|
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 `
|
|
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
|
-
`
|
|
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
|
|
205
|
-
`A
|
|
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. `
|
|
213
|
-
//
|
|
214
|
-
//
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
253
|
-
//
|
|
254
|
-
//
|
|
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
|
-
|
|
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
|
|
296
|
+
const branches = (entries || []).flatMap((entry, i) => resolveEntry(entry, [], i, ''))
|
|
292
297
|
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
}
|
package/src/uwx/asset-map.js
CHANGED
|
@@ -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 `
|
|
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
|
package/src/uwx/backfill.js
CHANGED
|
@@ -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
|
|
350
|
-
//
|
|
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
|
package/src/uwx/entity-source.js
CHANGED
|
@@ -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. `
|
|
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
|
|
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. `
|
|
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
|
|
16
|
-
// and `
|
|
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
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
-
// ⛔
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
* ⭐
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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>}
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
173
|
-
*
|
|
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
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
//
|
|
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
|
|
204
|
-
// read identically and a reader who takes it for a placement
|
|
205
|
-
// emitter is dropping a branch it never had. *(Measured 2026-08-31:
|
|
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 `
|
|
248
|
+
// was naming the file `articles/outdoor-hygge.md`.)*
|
|
209
249
|
const warnings = missing.map(
|
|
210
250
|
(id) =>
|
|
211
|
-
`
|
|
212
|
-
`entity was produced for it
|
|
213
|
-
`
|
|
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 = {
|