@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
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 = {
|
package/src/uwx/index.js
CHANGED
|
@@ -44,7 +44,6 @@ export {
|
|
|
44
44
|
emitSiteSyncPackage,
|
|
45
45
|
writeSiteEntityUuid,
|
|
46
46
|
writeSiteOrg,
|
|
47
|
-
writeSiteBackend,
|
|
48
47
|
extensionDeclaration,
|
|
49
48
|
isExtensionUrl,
|
|
50
49
|
isSiteRelativeExtensionUrl,
|
|
@@ -77,14 +76,27 @@ export {
|
|
|
77
76
|
localeFilePath,
|
|
78
77
|
} from './locale-sync.js'
|
|
79
78
|
export { emitSyncPackages } from './sync-package.js'
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
79
|
+
// ⚠️ The asset MAP is `sync.json::backends.<origin>.assets` (sync-store.js, below).
|
|
80
|
+
// `readAssetMap` / `updateAssetMap` / `refForAssetId` / `ASSET_MAP_FILE` were removed
|
|
81
|
+
// on 2026-09-20: a single flat file cannot hold ids minted by more than one backend.
|
|
82
|
+
export { restoreAssetRefs, servedFingerprint } from './asset-map.js'
|
|
83
|
+
// `sync.json` — what each backend minted, keyed by origin. Absorbs `assets.json`
|
|
84
|
+
// above, `site.yml`'s identity keys and three maps out of the sync cache.
|
|
85
|
+
// Spec: kb/framework/reference/sync-json.md
|
|
86
|
+
export {
|
|
87
|
+
SYNC_STORE_FILE,
|
|
88
|
+
readSyncStore,
|
|
89
|
+
readBackendState,
|
|
90
|
+
listSyncedBackends,
|
|
91
|
+
updateBackendState,
|
|
92
|
+
updateBackendMap,
|
|
93
|
+
carryServed,
|
|
94
|
+
clearBackend,
|
|
95
|
+
clearBackendSections,
|
|
96
|
+
forgetSyncStore,
|
|
83
97
|
refForAssetId,
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
servedFingerprint,
|
|
87
|
-
} from './asset-map.js'
|
|
98
|
+
normalizeOrigin as normalizeBackendOrigin,
|
|
99
|
+
} from './sync-store.js'
|
|
88
100
|
export {
|
|
89
101
|
diffSiteUnits,
|
|
90
102
|
describeSiteDiff,
|
|
@@ -104,23 +116,23 @@ export {
|
|
|
104
116
|
} from './records.js'
|
|
105
117
|
export { readEntityFile, parseFrontmatter } from './entity-source.js'
|
|
106
118
|
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
// The pool's grammar — a schema ref to its directory and back. ⛔ Allowlist: an
|
|
111
|
-
// export added to `site/entity-pool.js` does not reach a caller until it is here.
|
|
119
|
+
// Where a site's records live, and the grammar of that directory — a schema ref to
|
|
120
|
+
// its folder and back. ⛔ Allowlist: an export added to `site/entity-pool.js` does
|
|
121
|
+
// not reach a caller until it is here.
|
|
112
122
|
export {
|
|
113
123
|
poolDirsForSchema,
|
|
114
124
|
schemaForPoolDirs,
|
|
115
|
-
|
|
125
|
+
readEntityPool,
|
|
126
|
+
resolveRecordsDir,
|
|
127
|
+
RECORDS_DIR,
|
|
116
128
|
} from '../site/entity-pool.js'
|
|
117
129
|
|
|
130
|
+
// How the folder is organized, so a verb can ask what `records.yml` SAYS without
|
|
131
|
+
// re-reading or re-parsing it. ⛔ Note the allowlist: an export added to
|
|
132
|
+
// `site/records-config.js` does not reach a caller until it is named here.
|
|
118
133
|
export {
|
|
119
134
|
readRecordsConfig,
|
|
120
135
|
RECORDS_YML_RELPATH,
|
|
121
|
-
FOLDER_MISSING,
|
|
122
|
-
FOLDER_EMPTY,
|
|
123
|
-
FOLDER_DECLARED,
|
|
124
136
|
} from '../site/records-config.js'
|
|
125
137
|
export {
|
|
126
138
|
findRecordFile,
|