@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
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/folder.yml` (ruled 2026-09-21 [Diego]). Every file in `records/` is a
|
|
34
|
+
// record and sits at the top of the folder unless `folder.yml` places it in a sub-folder. The
|
|
35
|
+
// 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: `folder.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
|
@@ -61,7 +61,7 @@ export { buildFolderEntity,
|
|
|
61
61
|
export {
|
|
62
62
|
recordsToProject,
|
|
63
63
|
declarationsToQueriesYml,
|
|
64
|
-
|
|
64
|
+
folderToFolderYml,
|
|
65
65
|
findRecordFileByUuid,
|
|
66
66
|
} from './records-project.js'
|
|
67
67
|
export {
|
|
@@ -116,23 +116,24 @@ export {
|
|
|
116
116
|
} from './records.js'
|
|
117
117
|
export { readEntityFile, parseFrontmatter } from './entity-source.js'
|
|
118
118
|
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
// The pool's grammar — a schema ref to its directory and back. ⛔ Allowlist: an
|
|
123
|
-
// 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.
|
|
124
122
|
export {
|
|
125
123
|
poolDirsForSchema,
|
|
126
124
|
schemaForPoolDirs,
|
|
127
|
-
|
|
125
|
+
readEntityPool,
|
|
126
|
+
resolveRecordsDir,
|
|
127
|
+
RECORDS_DIR,
|
|
128
128
|
} from '../site/entity-pool.js'
|
|
129
129
|
|
|
130
|
+
// How the folder is organized, so a verb can ask what `records/folder.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.
|
|
130
133
|
export {
|
|
131
134
|
readRecordsConfig,
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
FOLDER_EMPTY,
|
|
135
|
-
FOLDER_DECLARED,
|
|
135
|
+
FOLDER_YML,
|
|
136
|
+
folderYmlPath,
|
|
136
137
|
} from '../site/records-config.js'
|
|
137
138
|
export {
|
|
138
139
|
findRecordFile,
|
|
@@ -19,7 +19,7 @@ import { proseMirrorToMarkdown, serializeFrontmatter } from '@uniweb/content-wri
|
|
|
19
19
|
import { parseFrontmatter } from './entity-source.js'
|
|
20
20
|
import { renderEntityDocument } from './backfill.js'
|
|
21
21
|
import { queriesYmlPath } from './queries-config.js'
|
|
22
|
-
import {
|
|
22
|
+
import { folderYmlPath } from '../site/records-config.js'
|
|
23
23
|
import { DECLARATION_KEYS } from '../site/fetch-shapes.js'
|
|
24
24
|
|
|
25
25
|
// Frontmatter keys that belong to the CCA framework / the developer's local
|
|
@@ -294,23 +294,25 @@ export function writeQueriesConfig(siteRoot, queries) {
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
/**
|
|
297
|
-
* Write `
|
|
297
|
+
* Write the records folder's organization — `folder.yml` in the records directory
|
|
298
|
+
* (`records/`, or wherever `site.yml::paths.records` puts it), as a LIST.
|
|
298
299
|
*
|
|
299
300
|
* ⛔ A FULL WRITE, NOT A MERGE, and that is the one place this differs from every
|
|
300
|
-
* other projected config.
|
|
301
|
-
* sides, nothing to invert, so a pull is a mirror rather than an update.
|
|
302
|
-
* also nothing a shallow merge could mean here — the file is a sequence,
|
|
303
|
-
* merging two lists either duplicates entries or silently drops them.
|
|
301
|
+
* other projected config. The file mirrors the folder's sub-folders: concrete refs
|
|
302
|
+
* on both sides, nothing to invert, so a pull is a mirror rather than an update.
|
|
303
|
+
* There is also nothing a shallow merge could mean here — the file is a sequence,
|
|
304
|
+
* and merging two lists either duplicates entries or silently drops them.
|
|
304
305
|
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
306
|
+
* The caller decides whether there is anything to write: a folder with no
|
|
307
|
+
* sub-folders is no file at all (`folderToFolderYml` removes it), since missing and
|
|
308
|
+
* empty mean the same. ⚠️ *Until 2026-09-21 this said an empty file was destructive
|
|
309
|
+
* on the next push; that stopped being true when the directory, not this file,
|
|
310
|
+
* became what decides which records the folder holds.*
|
|
309
311
|
*
|
|
310
312
|
* @returns {'updated'|'unchanged'}
|
|
311
313
|
*/
|
|
312
314
|
export function writeRecordsConfig(siteRoot, entries) {
|
|
313
|
-
return writeYamlFile(
|
|
315
|
+
return writeYamlFile(folderYmlPath(siteRoot).abs, entries)
|
|
314
316
|
}
|
|
315
317
|
|
|
316
318
|
/**
|