@uniweb/build 0.55.1 → 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/deploy-config-writer.js +137 -29
- package/src/site/deploy-config.js +87 -38
- package/src/site/entity-pool.js +136 -42
- package/src/site/index.js +2 -2
- 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 +28 -149
- package/src/uwx/backfill.js +31 -4
- package/src/uwx/entity-source.js +3 -3
- package/src/uwx/folder.js +89 -49
- package/src/uwx/index.js +29 -17
- package/src/uwx/records-project.js +131 -78
- package/src/uwx/records.js +324 -203
- package/src/uwx/self-scope.js +5 -5
- package/src/uwx/site-project.js +34 -15
- package/src/uwx/site.js +80 -90
- package/src/uwx/sync-package.js +23 -15
- package/src/uwx/sync-store.js +351 -0
- 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
|
@@ -1,159 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* and the writer is a no-op when nothing changed (it does not rewrite an
|
|
29
|
-
* identical file, so `git status` stays clean on a push that moved no assets).
|
|
30
|
-
*
|
|
31
|
-
* ## What it deliberately does NOT hold
|
|
32
|
-
*
|
|
33
|
-
* ⛔ **No serve URL.** A URL is a host's route layout, and storing one here would
|
|
34
|
-
* re-create — in a committed file, on every machine — exactly the coupling that
|
|
35
|
-
* deleting `buildAssetUrl` removed from this CLI. The id plus the host's
|
|
36
|
-
* `config.assets.url` pattern is the whole address, and only the host owns the
|
|
37
|
-
* second half.
|
|
38
|
-
*
|
|
39
|
-
* ⚖️ **What it holds instead is a FINGERPRINT of the served URL** (`served`), and the
|
|
40
|
-
* difference is the point: a hash can recognize an address and cannot compose one.
|
|
41
|
-
* It exists for references that are a BARE STRING — `info.preview`, `info.favicon`,
|
|
42
|
-
* `seo.image`, a section param — where there is no object to stamp `assetId` beside,
|
|
43
|
-
* so the stored value is the serve URL alone. `pull` hashes such a string and, when
|
|
44
|
-
* it matches, puts back the path the author wrote. Should the host ever serve an
|
|
45
|
-
* asset at a new address, the fingerprint simply stops matching and the pull leaves
|
|
46
|
-
* the URL — the honest projection — until the next push records the new one.
|
|
47
|
-
*
|
|
48
|
-
* ⛔ **No mime or size.** The store validates those and they are its to change;
|
|
49
|
-
* a second copy here is a second thing to disagree.
|
|
2
|
+
* Asset reference helpers — two pure functions, no file.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **The MAP moved to `sync.json` on 2026-09-20** (`sync-store.js`,
|
|
5
|
+
* `backends.<origin>.assets`). It lived here as `assets.json`, a single flat
|
|
6
|
+
* `local path → id` file that assumed ONE backend — which is exactly what it
|
|
7
|
+
* could not represent once a project syncs with several: an id is minted by a
|
|
8
|
+
* backend and means nothing to any other one. `readAssetMap` / `updateAssetMap` /
|
|
9
|
+
* `refForAssetId` / `ASSET_MAP_FILE` went with it.
|
|
10
|
+
*
|
|
11
|
+
* What stays here is what never touched the filesystem:
|
|
12
|
+
*
|
|
13
|
+
* - **`servedFingerprint`** — hashes a serve URL. It exists for references that are
|
|
14
|
+
* a BARE STRING (`info.preview`, `seo.image`, a section param), where there is no
|
|
15
|
+
* object to stamp an id beside, so the stored value is the address alone. ⛔ A
|
|
16
|
+
* fingerprint rather than the URL because a hash can RECOGNIZE an address and
|
|
17
|
+
* cannot COMPOSE one — route layout is the host's and never ours to rebuild.
|
|
18
|
+
* - **`restoreAssetRefs`** — puts an author's own paths back into a document pulled
|
|
19
|
+
* from a backend. Without it a push/pull cycle rewrites every image in a
|
|
20
|
+
* developer's source to a backend route: a mangling of files they own, by a round
|
|
21
|
+
* trip that changed nothing.
|
|
22
|
+
*
|
|
23
|
+
* ⭐ **`restoreAssetRefs` no-ops on an empty map**, which is what makes the store's
|
|
24
|
+
* `backend` parameter honest: a projection not tied to a backend has no known
|
|
25
|
+
* assets, so it restores nothing rather than guessing.
|
|
26
|
+
*
|
|
27
|
+
* @module
|
|
50
28
|
*/
|
|
51
29
|
|
|
52
|
-
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
53
30
|
import { createHash } from 'node:crypto'
|
|
54
31
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
55
|
-
import { join } from 'node:path'
|
|
56
|
-
|
|
57
|
-
export const ASSET_MAP_FILE = 'assets.json'
|
|
58
|
-
|
|
59
|
-
/** Current on-disk shape. Bumped only for a breaking layout change. */
|
|
60
|
-
const VERSION = 1
|
|
61
|
-
|
|
62
|
-
const mapPath = (siteDir) => join(siteDir, ASSET_MAP_FILE)
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Read the site's asset map. A missing, unreadable or malformed file reads as
|
|
66
|
-
* empty rather than throwing: the map is an accelerator and a record, and a
|
|
67
|
-
* corrupt one must never be the reason a push fails. The next write repairs it.
|
|
68
|
-
*
|
|
69
|
-
* @param {string} siteDir
|
|
70
|
-
* @returns {Record<string, { id: string, ext: string }>} local ref → identity
|
|
71
|
-
*/
|
|
72
|
-
export function readAssetMap(siteDir) {
|
|
73
|
-
const p = mapPath(siteDir)
|
|
74
|
-
if (!existsSync(p)) return {}
|
|
75
|
-
try {
|
|
76
|
-
const parsed = JSON.parse(readFileSync(p, 'utf8'))
|
|
77
|
-
const assets = parsed?.assets
|
|
78
|
-
if (!assets || typeof assets !== 'object') return {}
|
|
79
|
-
const out = {}
|
|
80
|
-
for (const [ref, v] of Object.entries(assets)) {
|
|
81
|
-
if (v && typeof v.id === 'string' && v.id) {
|
|
82
|
-
out[ref] = { id: v.id, ext: typeof v.ext === 'string' ? v.ext : '' }
|
|
83
|
-
if (typeof v.served === 'string' && v.served) out[ref].served = v.served
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
return out
|
|
87
|
-
} catch {
|
|
88
|
-
return {}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Merge `entries` into the site's map and write it if anything changed.
|
|
94
|
-
*
|
|
95
|
-
* MERGE, not replace: a push carries only the refs that page's content touched,
|
|
96
|
-
* so replacing would drop every asset this run happened not to mention — which
|
|
97
|
-
* is most of them on a partial push. An entry whose id changed is overwritten
|
|
98
|
-
* (the bytes at that path changed); an identical entry is left alone.
|
|
99
|
-
*
|
|
100
|
-
* @param {string} siteDir
|
|
101
|
-
* @param {Record<string, { id: string, ext: string }>} entries
|
|
102
|
-
* @returns {{ added: string[], changed: string[], written: boolean }}
|
|
103
|
-
*/
|
|
104
|
-
export function updateAssetMap(siteDir, entries) {
|
|
105
|
-
const prior = readAssetMap(siteDir)
|
|
106
|
-
const added = []
|
|
107
|
-
const changed = []
|
|
108
|
-
|
|
109
|
-
for (const [ref, v] of Object.entries(entries || {})) {
|
|
110
|
-
if (!v?.id) continue
|
|
111
|
-
const was = prior[ref]
|
|
112
|
-
// A new `served` fingerprint is a change too: the host serves these bytes at
|
|
113
|
-
// another address now, and the old fingerprint would stop recognizing it. An
|
|
114
|
-
// entry that carries none — a download learns identity, not an upload's URL —
|
|
115
|
-
// keeps the one already recorded for the same bytes.
|
|
116
|
-
const served = v.served || (was && was.id === v.id ? was.served : undefined)
|
|
117
|
-
if (!was) added.push(ref)
|
|
118
|
-
else if (was.id !== v.id || (served || '') !== (was.served || '')) changed.push(ref)
|
|
119
|
-
else continue
|
|
120
|
-
prior[ref] = { id: v.id, ext: v.ext || '', ...(served ? { served } : {}) }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (!added.length && !changed.length) return { added, changed, written: false }
|
|
124
|
-
|
|
125
|
-
// Sorted keys + a trailing newline: a committed file that reorders itself
|
|
126
|
-
// produces a diff on every push and teaches people to skip reading it.
|
|
127
|
-
const assets = {}
|
|
128
|
-
for (const ref of Object.keys(prior).sort()) assets[ref] = prior[ref]
|
|
129
|
-
writeFileSync(
|
|
130
|
-
mapPath(siteDir),
|
|
131
|
-
JSON.stringify({ version: VERSION, assets }, null, 2) + '\n'
|
|
132
|
-
)
|
|
133
|
-
return { added, changed, written: true }
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* The local ref a given asset id is known at, or null.
|
|
138
|
-
*
|
|
139
|
-
* This is the direction `pull` needs and the reason the map is worth
|
|
140
|
-
* committing: stored content carries an id, and only this can say the author
|
|
141
|
-
* called it `/images/hero.png`. Without it a pull must invent a path.
|
|
142
|
-
*
|
|
143
|
-
* @param {Record<string, { id: string, ext: string }>} map
|
|
144
|
-
* @param {string} id
|
|
145
|
-
* @returns {string|null}
|
|
146
|
-
*/
|
|
147
|
-
export function refForAssetId(map, id) {
|
|
148
|
-
if (!id) return null
|
|
149
|
-
for (const [ref, v] of Object.entries(map || {})) {
|
|
150
|
-
if (v.id === id) return ref
|
|
151
|
-
}
|
|
152
|
-
return null
|
|
153
|
-
}
|
|
154
32
|
|
|
155
33
|
/**
|
|
156
|
-
* The fingerprint `
|
|
34
|
+
* The fingerprint the asset map in `sync.json` keeps of the URL a host serves an
|
|
35
|
+
* asset at.
|
|
157
36
|
*
|
|
158
37
|
* A hash, never the URL: it can recognize an address a pull brings back and it
|
|
159
38
|
* cannot be used to compose one — see the header. Prefixed so a reader of the
|
package/src/uwx/backfill.js
CHANGED
|
@@ -318,13 +318,21 @@ function writeIfChanged(filePath, text) {
|
|
|
318
318
|
* order: `{ id, model, slug, sourceFile, format?, multiRecord?, declaration? }`.
|
|
319
319
|
* @param {object[]} params.finalized - response entries `{ index, uuid, changed?, document? }`.
|
|
320
320
|
* @param {string} [params.sourceLocale]
|
|
321
|
-
* @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[] }}
|
|
321
|
+
* @returns {{ updated: string[], unchanged: string[], deferred: object[], warnings: string[], mapped: Object<string,string> }}
|
|
322
|
+
* `mapped` is own id → the uuid this backend minted, for the caller to record per backend.
|
|
322
323
|
*/
|
|
323
324
|
export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
324
325
|
const updated = []
|
|
325
326
|
const unchanged = []
|
|
326
327
|
const deferred = []
|
|
327
328
|
const warnings = []
|
|
329
|
+
// ⭐ own id → the uuid THIS backend minted. The caller files it under the backend in
|
|
330
|
+
// `sync.json::backends.<origin>.records`; this function knows no backend.
|
|
331
|
+
const mapped = {}
|
|
332
|
+
// Files holding only records that already carry their own id. Counted ONCE per file
|
|
333
|
+
// at the end: an array-form file holds several records, and "examined, nothing
|
|
334
|
+
// written" is a statement about the file, not about each record in it.
|
|
335
|
+
const ownIdFiles = new Set()
|
|
328
336
|
// Multi-record files are written ONCE per file, applying every (slug → uuid).
|
|
329
337
|
const arrayFiles = new Map() // array-form YAML/JSON: sourceFile -> Map(slug -> uuid)
|
|
330
338
|
const bibFiles = new Map() // BibTeX: sourceFile -> Map(cite key -> uuid)
|
|
@@ -338,8 +346,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
338
346
|
warnings.push(`finalized index ${i} has no matching submitted entity`)
|
|
339
347
|
continue
|
|
340
348
|
}
|
|
341
|
-
// Skip the non-record entities: the site-content entity (its uuid is
|
|
342
|
-
//
|
|
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
|
|
343
351
|
// (no uuid to back-fill — the backend owns the site's folder, keyed by the
|
|
344
352
|
// site-content uuid). Both are positional placeholders with no record source file.
|
|
345
353
|
if (entry.kind === 'site' || entry.kind === 'folder') continue
|
|
@@ -347,6 +355,22 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
347
355
|
deferred.push({ index: i, id: entry.id, reason: 'no source file on disk' })
|
|
348
356
|
continue
|
|
349
357
|
}
|
|
358
|
+
|
|
359
|
+
// ⭐ Every record the backend returned is mapped: own id → the uuid it minted.
|
|
360
|
+
// A record new to EVERY backend has no own id yet, so the minted uuid becomes it
|
|
361
|
+
// (written below) and the mapping is identity — which is exactly the behaviour
|
|
362
|
+
// before identity was keyed by backend, so a single-backend project is unchanged.
|
|
363
|
+
mapped[entry.ownId || uuid] = uuid
|
|
364
|
+
|
|
365
|
+
// ⛔ A record that already carries its OWN id is NOT rewritten. Its id is stable
|
|
366
|
+
// and travels with the file; this backend's uuid belongs in the map, not in the
|
|
367
|
+
// author's file. And it must not be: variant A below re-renders the WHOLE file from
|
|
368
|
+
// the backend's document, which carries THIS backend's `$uuid` — so a second
|
|
369
|
+
// backend would silently overwrite the record's identity with its own.
|
|
370
|
+
if (entry.ownId) {
|
|
371
|
+
ownIdFiles.add(entry.sourceFile)
|
|
372
|
+
continue
|
|
373
|
+
}
|
|
350
374
|
if (entry.multiRecord) {
|
|
351
375
|
// Many records in one file → group by file, written once. BibTeX and
|
|
352
376
|
// array-form YAML/JSON both re-render canonically (sync owns the output);
|
|
@@ -405,5 +429,8 @@ export function backfillEntityUuids({ index, finalized, sourceLocale = 'en' }) {
|
|
|
405
429
|
else warnings.push(`${file}: ${res.message}`)
|
|
406
430
|
}
|
|
407
431
|
|
|
408
|
-
|
|
432
|
+
for (const file of ownIdFiles) {
|
|
433
|
+
if (!updated.includes(file) && !unchanged.includes(file)) unchanged.push(file)
|
|
434
|
+
}
|
|
435
|
+
return { updated, unchanged, deferred, warnings, mapped }
|
|
409
436
|
}
|
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
|