@uniweb/build 0.15.5 → 0.15.7
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 -5
- package/src/search/collections.js +2 -54
- package/src/search/extract.js +2 -335
- package/src/search/generate.js +8 -108
- package/src/search/index.js +8 -22
- package/src/site/build-site-data.js +80 -3
- package/src/site/content-collector.js +22 -4
- package/src/site/data-ball.js +15 -0
- package/src/site/plugin.js +130 -16
- package/src/uwx/collections-project.js +0 -1
- package/src/uwx/entity-document.js +34 -2
- package/src/uwx/index.js +9 -0
- package/src/uwx/site-diff.js +294 -0
- package/src/uwx/site-project.js +15 -4
- package/src/uwx/site.js +58 -11
- package/src/uwx/sync-package.js +81 -3
package/src/uwx/site.js
CHANGED
|
@@ -16,10 +16,18 @@
|
|
|
16
16
|
//
|
|
17
17
|
// IDENTITY. The ENTITY `$uuid` lives in `site.yml` (top-level `$uuid`); we read it,
|
|
18
18
|
// send it, and back-fill the minted value there. Nested pages/sections carry a `$id`
|
|
19
|
-
// handle
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
19
|
+
// handle AND a per-item `$uuid`.
|
|
20
|
+
//
|
|
21
|
+
// The per-item uuid is NOT authored — author files never carry sync uuids. It is
|
|
22
|
+
// stamped at emit (`stampUnitUuids`) from an out-of-band cache populated by whatever
|
|
23
|
+
// the backend last reported: a pull, or a push response's `finalized[].document`.
|
|
24
|
+
// This is load-bearing, not bookkeeping: the backend matches records by uuid, and
|
|
25
|
+
// `pages` / `page_sections` / `layout_sections` are all `multi` sections, where a
|
|
26
|
+
// uuid-less record is read as NEW — inserted, with its stored counterpart deleted as
|
|
27
|
+
// host-only. Sending without it replaced every page and section row on every push,
|
|
28
|
+
// which silently invalidated the per-item handles the app holds for its own
|
|
29
|
+
// concurrency. (This note previously said the opposite and described the wholesale
|
|
30
|
+
// treatment as intended; it was neither intended nor harmless.)
|
|
23
31
|
//
|
|
24
32
|
// `@`-prefix child sections declared in `page.yml::nest:` ARE reconstructed —
|
|
25
33
|
// they ride under their parent section's `$children` (page_sections is
|
|
@@ -238,10 +246,12 @@ const DYNAMIC_RE = /^\[(.+)\]$/
|
|
|
238
246
|
// - genuine self-nesting uses `$children`: a folder's child pages (within
|
|
239
247
|
// `pages`), and `@`-prefix child sections declared in `nest:` (within
|
|
240
248
|
// `page_sections`). Cross-section parentage is pure structure, never `$parent`.
|
|
241
|
-
// -
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// the
|
|
249
|
+
// - `$id` (the stableId — the in-file handle) rides at every item level as the
|
|
250
|
+
// wire-only closure handle. Per-item `$uuid` is NOT authored here: it is
|
|
251
|
+
// stamped on by `stampUnitUuids` at emit, from the out-of-band identity cache,
|
|
252
|
+
// because the backend matches records by uuid and a uuid-less record in a
|
|
253
|
+
// `multi` section is read as new (inserted, stored counterpart deleted). See
|
|
254
|
+
// the IDENTITY note in the file header and `site-diff.js`.
|
|
245
255
|
//
|
|
246
256
|
// v0 deferrals: folder-mode `.md`-as-pages, `paths:` mounts, versioned scopes, and
|
|
247
257
|
// media/asset bytes (favicon/assets, carried out of band). `@`-prefix `nest:`
|
|
@@ -253,8 +263,9 @@ const SITE_MODEL_NAME = '@uniweb/site-content'
|
|
|
253
263
|
// `$id` (the handle), then the record's fields — the wire's canonical key order.
|
|
254
264
|
// `fields` already carries `stable_id` (the Model field); `$id` is the same value.
|
|
255
265
|
// Both are kept: `$id` is the sync handle, `stable_id` is the declared content field
|
|
256
|
-
// the editor/render reads.
|
|
257
|
-
// the
|
|
266
|
+
// the editor/render reads. Per-item `$uuid` is added later by `stampUnitUuids` (it
|
|
267
|
+
// comes from the identity cache, not from the authored files) — see the IDENTITY
|
|
268
|
+
// note in the file header.
|
|
258
269
|
function withIdentity(id, fields) {
|
|
259
270
|
return Object.assign({ $id: id }, fields)
|
|
260
271
|
}
|
|
@@ -308,11 +319,21 @@ function isFullyExplicitSections(sectionsConfig) {
|
|
|
308
319
|
)
|
|
309
320
|
}
|
|
310
321
|
|
|
322
|
+
// The same list plus a `...` rest marker: the named entries still give order and
|
|
323
|
+
// nesting, and anything undiscovered is appended. `uniweb pull` writes exactly this
|
|
324
|
+
// shape, because a bare list would make the page STRICT and silently exclude every
|
|
325
|
+
// section added after the pull.
|
|
326
|
+
function isExplicitWithRest(sectionsConfig) {
|
|
327
|
+
if (!Array.isArray(sectionsConfig) || sectionsConfig.length === 0) return false
|
|
328
|
+
if (!sectionsConfig.includes('...')) return false
|
|
329
|
+
return isFullyExplicitSections(sectionsConfig.filter((i) => i !== '...'))
|
|
330
|
+
}
|
|
331
|
+
|
|
311
332
|
// Build the page_sections tree from an explicit `sections:` array: each item is
|
|
312
333
|
// a section name (string) or `{ name: [children…] }`, resolved to its file by
|
|
313
334
|
// name (bare / `@` / numeric-prefix tolerant) and recursed. Order and nesting
|
|
314
335
|
// come from the array, not the directory.
|
|
315
|
-
async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig) {
|
|
336
|
+
async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig, { appendRest = false } = {}) {
|
|
316
337
|
const mdFiles = (await readdir(pageDir)).filter(isMarkdownFile).sort(compareFilenames)
|
|
317
338
|
const seen = new Set()
|
|
318
339
|
|
|
@@ -335,9 +356,25 @@ async function collectPageSectionsExplicit(pageDir, siteRoot, sectionsConfig) {
|
|
|
335
356
|
|
|
336
357
|
const sections = []
|
|
337
358
|
for (const item of sectionsConfig) {
|
|
359
|
+
if (item === '...') continue // the rest marker itself names no section
|
|
338
360
|
const s = await buildItem(item)
|
|
339
361
|
if (s) sections.push(s)
|
|
340
362
|
}
|
|
363
|
+
|
|
364
|
+
// `...` — append any top-level file the list didn't claim, in filename order, so
|
|
365
|
+
// a section added after a pull appears instead of being silently dropped.
|
|
366
|
+
// `seen` already holds every file the list used, children included, so a nested
|
|
367
|
+
// child is never also promoted to a sibling of its own parent.
|
|
368
|
+
if (appendRest) {
|
|
369
|
+
for (const file of mdFiles) {
|
|
370
|
+
if (isChildSection(file) || seen.has(file)) continue
|
|
371
|
+
seen.add(file)
|
|
372
|
+
const stableDefault = parseNumericPrefix(stripAtPrefix(parse(file).name)).name
|
|
373
|
+
const { section } = await processMarkdownFile(join(pageDir, file), String(seen.size), siteRoot, stableDefault)
|
|
374
|
+
section.subsections = []
|
|
375
|
+
sections.push(section)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
341
378
|
return sections.map((s, i) => sectionToRecord(s, i))
|
|
342
379
|
}
|
|
343
380
|
|
|
@@ -345,6 +382,9 @@ async function collectPageSectionsNested(pageDir, siteRoot, pageConfig) {
|
|
|
345
382
|
if (isFullyExplicitSections(pageConfig?.sections)) {
|
|
346
383
|
return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections)
|
|
347
384
|
}
|
|
385
|
+
if (isExplicitWithRest(pageConfig?.sections)) {
|
|
386
|
+
return collectPageSectionsExplicit(pageDir, siteRoot, pageConfig.sections, { appendRest: true })
|
|
387
|
+
}
|
|
348
388
|
const mdFiles = (await readdir(pageDir)).filter(isMarkdownFile).sort(compareFilenames)
|
|
349
389
|
const nest = pageConfig?.nest && typeof pageConfig.nest === 'object' ? pageConfig.nest : {}
|
|
350
390
|
|
|
@@ -589,6 +629,13 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
589
629
|
setIf(info, 'fetcher', siteYml.fetcher)
|
|
590
630
|
setIf(info, 'build', siteYml.build)
|
|
591
631
|
setIf(info, 'search', siteYml.search)
|
|
632
|
+
// `agents` — the projections opt-out + route exclusions. Carried because the
|
|
633
|
+
// app is a second PUBLISHER of projections and derives them from stored
|
|
634
|
+
// content: without this block it cannot see `agents: false` or
|
|
635
|
+
// `agents.exclude`, so an author's opt-out is silently reversed and an
|
|
636
|
+
// excluded branch becomes both discoverable AND summarized by the index.
|
|
637
|
+
// (The CLI lane reads site.yml directly and honors it either way.)
|
|
638
|
+
setIf(info, 'agents', siteYml.agents)
|
|
592
639
|
setIf(info, 'paths', siteYml.paths)
|
|
593
640
|
setIf(info, 'data', siteYml.data ?? siteYml.fetch)
|
|
594
641
|
// `app` — the deployment's `@uniweb/app-spec` reference (a bare uuid string),
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { buildCollectionEntities, entityContentHash } from './collections.js'
|
|
23
23
|
import { buildFolderEntity } from './folder.js'
|
|
24
24
|
import { siteProjectToDocument } from './site.js'
|
|
25
|
+
import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
|
|
25
26
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
26
27
|
import { isLocalAssetPath } from '../site/assets.js'
|
|
27
28
|
|
|
@@ -30,6 +31,33 @@ const SITE_ENTITY_KEY = 'site-content'
|
|
|
30
31
|
|
|
31
32
|
const cacheKey = (entity) => `${entity.model} ${entity.id}`
|
|
32
33
|
|
|
34
|
+
// Attach the push gate's optimistic-concurrency token to an entity, keyed by the
|
|
35
|
+
// BACKEND uuid in its document body (`$uuid`). A never-synced entity has no
|
|
36
|
+
// `$uuid` and therefore no base — correctly unconditional, since there is no
|
|
37
|
+
// stored state it could be stale against. `baseVersions` is the caller's
|
|
38
|
+
// uuid→version map (the sync-cache); an entity the map doesn't know is left
|
|
39
|
+
// unconditional rather than guessed at. See entity-document.js for what the
|
|
40
|
+
// token means on the wire.
|
|
41
|
+
const withBaseVersion = (baseVersions, itemBaseVersions) => (entity) => {
|
|
42
|
+
const uuid = entity?.document?.$uuid
|
|
43
|
+
const v = uuid ? baseVersions[uuid] : null
|
|
44
|
+
// Per-item preconditions, narrowed to the records THIS package actually carries.
|
|
45
|
+
// The cache spans the whole site, but a package holds only what changed; sending
|
|
46
|
+
// tokens for absent records would bloat the manifest and assert preconditions on
|
|
47
|
+
// items we are not touching.
|
|
48
|
+
let items = null
|
|
49
|
+
if (itemBaseVersions && Object.keys(itemBaseVersions).length) {
|
|
50
|
+
items = {}
|
|
51
|
+
for (const itemUuid of Object.values(collectUnitUuids(entity.document))) {
|
|
52
|
+
const t = itemBaseVersions[itemUuid]
|
|
53
|
+
if (t) items[itemUuid] = t
|
|
54
|
+
}
|
|
55
|
+
if (!Object.keys(items).length) items = null
|
|
56
|
+
}
|
|
57
|
+
if (!v && !items) return entity
|
|
58
|
+
return { ...entity, ...(v ? { baseVersion: v } : {}), ...(items ? { itemBaseVersions: items } : {}) }
|
|
59
|
+
}
|
|
60
|
+
|
|
33
61
|
function emitLane(entities, exporter, exportedAt, extraModels = []) {
|
|
34
62
|
const models = [...new Set([...entities.map((e) => e.model), ...extraModels])]
|
|
35
63
|
const buffer = emitEntitySyncPackage({
|
|
@@ -113,6 +141,21 @@ function rewriteEntityAssets(node, map) {
|
|
|
113
141
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale
|
|
114
142
|
* @param {Object<string,string>} [opts.priorHashes] - sync-cache (send-only-changed)
|
|
115
143
|
* @param {boolean} [opts.sendAll] - bypass the prior-hash filter
|
|
144
|
+
* @param {Object<string,string>} [opts.itemUuids] - unit path → backend `$uuid`,
|
|
145
|
+
* stamped onto the site-content document so the backend matches our items
|
|
146
|
+
* instead of re-minting them (which deletes and recreates every page and
|
|
147
|
+
* section row). Sourced from a pull or a push response, cached by the caller.
|
|
148
|
+
* @param {Object<string,string>} [opts.itemBaseVersions] - record `$uuid` → opaque
|
|
149
|
+
* per-ITEM `version`. Narrowed at emit to the records the package carries and
|
|
150
|
+
* sent as `entries[].item_base_versions`, so the backend can refuse only the
|
|
151
|
+
* records that genuinely moved instead of the whole document. Omit to push
|
|
152
|
+
* those records unconditionally.
|
|
153
|
+
* @param {Object<string,string>} [opts.baseVersions] - backend-uuid → opaque
|
|
154
|
+
* `version` token, the push gate's optimistic-concurrency precondition.
|
|
155
|
+
* Each sent entity whose `$uuid` the map knows carries it as a top-level
|
|
156
|
+
* `entries[].base_version`; the backend refuses the whole package
|
|
157
|
+
* atomically if its stored version has moved. Omit the map (or an entry)
|
|
158
|
+
* to push unconditionally — that IS the force path.
|
|
116
159
|
* @param {boolean} [opts.includeSite] - include the site-content lane (default true)
|
|
117
160
|
* @param {object} [opts.injectInfo] - deploy-derived `info.*` to stamp on the
|
|
118
161
|
* site-content document (e.g. `{ data_bundle }`, the static-data ball URL);
|
|
@@ -140,6 +183,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
140
183
|
const sourceLocale = opts.sourceLocale
|
|
141
184
|
const priorHashes = opts.priorHashes || {}
|
|
142
185
|
const sendAll = !!opts.sendAll
|
|
186
|
+
const stamp = withBaseVersion(opts.baseVersions || {}, opts.itemBaseVersions || {})
|
|
143
187
|
const exporter = opts.exporter
|
|
144
188
|
const exportedAt = opts.exportedAt
|
|
145
189
|
|
|
@@ -166,6 +210,33 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
166
210
|
siteDoc.info = { ...siteDoc.info, ...opts.injectInfo }
|
|
167
211
|
}
|
|
168
212
|
|
|
213
|
+
// Per-item identity. `pages`, `page_sections` and `layout_sections` are all
|
|
214
|
+
// `multi` sections on the backend's Model, and its reconcile matches a record by
|
|
215
|
+
// `$uuid`: a record without one is read as NEW, so it is inserted and the stored
|
|
216
|
+
// counterpart falls out as host-only and is DELETED. Pushing identity-blind
|
|
217
|
+
// therefore replaces every page and section row on every push — the content still
|
|
218
|
+
// lands, which is why it hid, but the app's per-item concurrency handles all point
|
|
219
|
+
// at rows that no longer exist.
|
|
220
|
+
//
|
|
221
|
+
// The uuids come from whatever the backend last told us (a pull, or a push
|
|
222
|
+
// response's `finalized[].document`), cached out-of-band by the caller so author
|
|
223
|
+
// files never carry sync uuids. A unit the map doesn't know keeps no `$uuid` —
|
|
224
|
+
// that is new content on its first push, where minting is correct.
|
|
225
|
+
//
|
|
226
|
+
// Stamping does NOT perturb the send-only-changed hash: `entityContentHash`
|
|
227
|
+
// strips `$`-sigils, so adopting this never re-fires an unchanged lane.
|
|
228
|
+
let itemIdentity = null
|
|
229
|
+
if (siteDoc && opts.itemUuids && typeof opts.itemUuids === 'object') {
|
|
230
|
+
itemIdentity = stampUnitUuids(siteDoc, opts.itemUuids, sourceLocale)
|
|
231
|
+
for (const path of itemIdentity.collisions) {
|
|
232
|
+
warnings.push(
|
|
233
|
+
`Two sections resolve to the same file (${path}) — their stable ids collide ` +
|
|
234
|
+
`(e.g. \`1-name.md\` and \`name.md\`). Only the first keeps its identity, and a pull ` +
|
|
235
|
+
`would collapse them into one file. Rename one.`
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
169
240
|
// Local-media over push (Slice 5). `assetRewrite` ({ '/images/x.png': serveUrl })
|
|
170
241
|
// is supplied by the deploy's SECOND emit, after it has uploaded the files the
|
|
171
242
|
// FIRST emit surfaced in `localAssets`. It is absent on the collect emit and on
|
|
@@ -225,7 +296,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
225
296
|
// Folder first (always, for the `$ref` closure), then changed records. The
|
|
226
297
|
// leading `{ kind: 'folder' }` keeps submission position 0 aligned for record
|
|
227
298
|
// back-fill (backfillEntityUuids skips it — the folder has no uuid to write).
|
|
228
|
-
const entities = [folder, ...changedRecords.map((r) => r.entity)]
|
|
299
|
+
const entities = [folder, ...changedRecords.map((r) => r.entity)].map(stamp)
|
|
229
300
|
const index = [{ kind: 'folder' }, ...changedRecords.map((r) => r.index)]
|
|
230
301
|
// The folder references every record's Model via `entry.model` — including records
|
|
231
302
|
// filtered out here by send-only-changed. Declare them all (the backend rejects a
|
|
@@ -237,7 +308,7 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
237
308
|
// --- site-content lane -------------------------------------------------------
|
|
238
309
|
let siteContent = null
|
|
239
310
|
if (siteEntity && changed(siteEntity)) {
|
|
240
|
-
siteContent = { ...emitLane([siteEntity], exporter, exportedAt), index: [{ kind: 'site' }] }
|
|
311
|
+
siteContent = { ...emitLane([stamp(siteEntity)], exporter, exportedAt), index: [{ kind: 'site' }] }
|
|
241
312
|
}
|
|
242
313
|
|
|
243
314
|
// The site's current uuid (from site.yml): the verb keys the folder push route on
|
|
@@ -245,5 +316,12 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
245
316
|
// the content lane by its presence/absence.
|
|
246
317
|
const siteContentUuid = siteDoc?.$uuid
|
|
247
318
|
|
|
248
|
-
return {
|
|
319
|
+
return {
|
|
320
|
+
siteContent, collections, siteContentUuid, hashes, warnings, skipped,
|
|
321
|
+
schemaless: col.schemaless, localAssets,
|
|
322
|
+
// { stamped, unknown } when identity was applied; null when the caller passed
|
|
323
|
+
// no map. `unknown > 0` with `stamped === 0` on a site that has been pushed
|
|
324
|
+
// before is the index-loss signature the backend refuses — the caller reports it.
|
|
325
|
+
itemIdentity,
|
|
326
|
+
}
|
|
249
327
|
}
|