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