@uniweb/build 0.15.5 → 0.15.6

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.
@@ -100,6 +100,7 @@ const INFO_TO_SITE_YML = {
100
100
  fetcher: 'fetcher',
101
101
  build: 'build',
102
102
  search: 'search',
103
+ agents: 'agents',
103
104
  paths: 'paths',
104
105
  data: 'data',
105
106
  template: 'template',
@@ -259,7 +260,7 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
259
260
 
260
261
  // A section/page record's durable handle: the `stable_id` content field (which
261
262
  // survives the round trip), falling back to the `$id` transport handle.
262
- function recordStableId(record) {
263
+ export function recordStableId(record) {
263
264
  return record?.stable_id || record?.$id || null
264
265
  }
265
266
 
@@ -274,7 +275,7 @@ function recordStableId(record) {
274
275
  // collide on one filename (a collision would silently drop a section). The
275
276
  // `page.yml::sections:` leaf uses this same safe base so file resolution matches.
276
277
  const SAFE_STABLE_ID = /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/
277
- function safeStableIdFilename(stableId) {
278
+ export function safeStableIdFilename(stableId) {
278
279
  if (SAFE_STABLE_ID.test(stableId)) return stableId
279
280
  const base = stableId
280
281
  .replace(/[^A-Za-z0-9._-]+/g, '-')
@@ -361,7 +362,17 @@ function pageRecordToYml(record, sectionsArray, sourceLocale) {
361
362
  if (record.layout !== undefined) y.layout = record.layout
362
363
  if (record.seo !== undefined) y.seo = record.seo
363
364
  if (record.fetch !== undefined) y.fetch = record.fetch
364
- if (sectionsArray && sectionsArray.length > 0) y.sections = sectionsArray
365
+ // `sections:` exists to preserve ORDER and NESTING, which the projected filenames
366
+ // can't carry (they're `<stableId>.md`, with no numeric prefix). It must not also
367
+ // decide MEMBERSHIP — and a bare list does: the collector reads a list without
368
+ // `...` as strict, "only listed sections processed". So a pulled page silently
369
+ // excluded any section added afterwards. You'd create the file, push, and be told
370
+ // "nothing to push", with nothing anywhere explaining why.
371
+ //
372
+ // The trailing `...` makes it inclusive: listed sections keep their order and
373
+ // their nesting, and anything new is discovered and appended as it would be in a
374
+ // page that was never pulled.
375
+ if (sectionsArray && sectionsArray.length > 0) y.sections = [...sectionsArray, '...']
365
376
  return y
366
377
  }
367
378
 
@@ -409,7 +420,7 @@ function projectPages(pages, pagesDir, sourceLocale, report, prune, ctx) {
409
420
  // `slug` is a localized `{lang: value}` map on the wire (a page route stays
410
421
  // localized); the directory uses the canonical SOURCE-locale slug. `param_name`
411
422
  // is already a plain string.
412
- function pageDirName(record, sourceLocale) {
423
+ export function pageDirName(record, sourceLocale) {
413
424
  const slug = unwrapLocalized(record.slug, sourceLocale)
414
425
  return record.is_dynamic ? `[${record.param_name || slug}]` : slug
415
426
  }
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 but NO per-item `$uuid` — the backend takes site-content content wholesale
20
- // (collision=force), so there is no per-item uuid round-trip on the wire. For the
21
- // eventual pull, per-item identity is recovered from the in-file `stableId` +
22
- // content-match, not a local id store.
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
- // - identity on the wire is `$id` (the stableId — the in-file handle) at every
242
- // item level, with NO per-item `$uuid` (the entity `$uuid` lives in site.yml;
243
- // the backend takes site-content content wholesale). See the IDENTITY note in
244
- // the file header.
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. No per-item `$uuid` on the wire (see the IDENTITY note in
257
- // the file header) items are content of the one site-content entity.
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),
@@ -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 { siteContent, collections, siteContentUuid, hashes, warnings, skipped, schemaless: col.schemaless, localAssets }
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
  }