@uniweb/build 0.29.0 → 0.29.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.29.0",
3
+ "version": "0.29.1",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -60,10 +60,10 @@
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
62
  "@uniweb/content-reader": "^1.2.4",
63
- "@uniweb/semantic-parser": "^1.3.1",
64
- "@uniweb/projections": "^0.5.1",
65
- "@uniweb/content-writer": "^0.3.4",
66
63
  "@uniweb/schemas": "^0.2.11",
64
+ "@uniweb/content-writer": "^0.3.4",
65
+ "@uniweb/projections": "^0.5.1",
66
+ "@uniweb/semantic-parser": "^1.3.1",
67
67
  "@uniweb/theming": "^0.1.15"
68
68
  },
69
69
  "optionalDependencies": {
@@ -0,0 +1,70 @@
1
+ // What shape a `fetch:` declaration is, and what a PROJECTION may write back.
2
+ //
3
+ // ⛔ WHY THIS EXISTS. A `fetch:` declaration has three shapes, and the keys each one
4
+ // accepts differ (`data-fetcher.js` RECOGNIZED_FETCH_KEYS):
5
+ //
6
+ // refine refine · inherit · detail · limit · sort · where · filter
7
+ // collection collection · schema · … — and NOT `path`/`url`
8
+ // source path · url · schema · …
9
+ //
10
+ // The build RESOLVES a `collection:` shorthand into a concrete location, so the
11
+ // declaration that rides the sync wire carries BOTH the authored `collection` and
12
+ // the derived `path`. Projecting that back verbatim writes a file that is neither
13
+ // shape cleanly: `collection` wins the classification, and the `path` beside it is
14
+ // then an unrecognized key on its own declaration.
15
+ //
16
+ // ⚠️ Measured on matinee 2026-08-29 — `push → pull → push`, where the third step is
17
+ // rejected by our OWN validator against a file our OWN projector had just written:
18
+ //
19
+ // fetch:
20
+ // path: /data/members.json ← derived; also a build artifact path
21
+ // schema: members
22
+ // collection: members ← what the author actually wrote
23
+ //
24
+ // [uniweb] fetch: unrecognized key "path" was ignored. Keys recognized on this
25
+ // declaration: collection, detailPage, filter, limit, merge, prerender, schema,
26
+ // sort, transform, where.
27
+ //
28
+ // ⭐ The round trip has to invert the resolution, not copy it. `/data/<name>.json`
29
+ // is a materialization of a collection, never its definition — so it is precisely
30
+ // the thing an authored file should not contain.
31
+ //
32
+ // ⚖️ DROPS ONLY WHAT IS DERIVABLE, not everything unrecognized. A key we do not know
33
+ // might be one a newer producer authored, and silently discarding it on every pull
34
+ // would make the round trip lossy in a way nothing reports. `path` and `url` beside
35
+ // a `collection` are recoverable from the collection itself; anything else survives
36
+ // and the validator's warning stays the honest signal.
37
+
38
+ /** Which of the three shapes a declaration is — the same order `data-fetcher` uses. */
39
+ export function fetchShapeOf(fetch) {
40
+ if (!fetch || typeof fetch !== 'object') return null
41
+ if (fetch.refine === true || fetch.inherit === true) return 'refine'
42
+ if (fetch.collection) return 'collection'
43
+ return 'source'
44
+ }
45
+
46
+ /** Keys a shape derives rather than the author writing them. */
47
+ const DERIVED_BY_SHAPE = {
48
+ collection: ['path', 'url'],
49
+ refine: [],
50
+ source: []
51
+ }
52
+
53
+ /**
54
+ * The declaration as an author would have written it — the wire's resolved form
55
+ * minus what the build derived.
56
+ *
57
+ * @param {object} fetch a `fetch:` declaration off the sync wire
58
+ * @returns {object} the same declaration, safe to write into authored config
59
+ */
60
+ export function authorableFetch(fetch) {
61
+ const shape = fetchShapeOf(fetch)
62
+ const derived = DERIVED_BY_SHAPE[shape]
63
+ if (!derived || derived.length === 0) return fetch
64
+ const out = {}
65
+ for (const [k, v] of Object.entries(fetch)) {
66
+ if (derived.includes(k)) continue
67
+ out[k] = v
68
+ }
69
+ return out
70
+ }
package/src/uwx/index.js CHANGED
@@ -90,6 +90,7 @@ export {
90
90
  collectSiteUnits,
91
91
  walkSiteUnits,
92
92
  collectUnitUuids,
93
+ collectCollectionUuids,
93
94
  stampUnitUuids,
94
95
  } from './site-diff.js'
95
96
  export {
@@ -292,3 +292,26 @@ export function describeSiteDiff(diff, { limit = 8 } = {}) {
292
292
  }
293
293
  return lines
294
294
  }
295
+
296
+
297
+ /**
298
+ * Collection-declaration identity from a document the BACKEND produced (a pull, or a
299
+ * push response's `finalized[].document`): `{ <collection name>: <$uuid> }`.
300
+ *
301
+ * ⛔ The sibling of collectFolderItemUuids, and it exists for the same reason: these
302
+ * items have no file of their own, so nothing in a path-keyed map can hold their
303
+ * identity, and a push that omits it re-sends the whole section uuid-less. The
304
+ * backend refuses that rather than deleting every stored row — see collectionsNested.
305
+ *
306
+ * Keyed by `name`, which the backend enforces unique within the section. `$id` is
307
+ * deliberately not consulted: it is a payload-local handle the backend never stores.
308
+ */
309
+ export function collectCollectionUuids(doc) {
310
+ const out = {}
311
+ for (const item of doc?.collections || []) {
312
+ const name = item?.name
313
+ const uuid = item?.$uuid
314
+ if (typeof name === 'string' && typeof uuid === 'string' && name && uuid) out[name] = uuid
315
+ }
316
+ return out
317
+ }
@@ -39,6 +39,7 @@ import { createHash } from 'node:crypto'
39
39
  import yaml from 'js-yaml'
40
40
  import { writeSiteConfig, writeThemeFile, writeIfChanged, writeSectionFile, writeMergedYaml } from './project-writer.js'
41
41
  import { declarationsToCollectionsYml } from './collections-project.js'
42
+ import { authorableFetch } from '../site/fetch-shapes.js'
42
43
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
43
44
  import { buildFreeformPath } from '../i18n/freeform.js'
44
45
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
@@ -303,7 +304,8 @@ export function sectionRecordToFile({ filePath, record, sourceLocale = LOCALIZED
303
304
  if (theme_override !== undefined) frontmatter.theme = theme_override
304
305
  if (preset !== undefined) frontmatter.preset = preset
305
306
  if (input !== undefined) frontmatter.input = input
306
- if (fetch !== undefined) frontmatter.fetch = fetch
307
+ // Invert the build's resolution rather than copy it — see fetch-shapes.js.
308
+ if (fetch !== undefined) frontmatter.fetch = authorableFetch(fetch)
307
309
  if (stable_id !== undefined) frontmatter.id = stable_id
308
310
 
309
311
  const body = insets ? reinlineInsets(sourceContent, insets) : sourceContent
@@ -424,7 +426,8 @@ function pageRecordToYml(record, sectionsArray, sourceLocale) {
424
426
  if (record.rewrite !== undefined) y.rewrite = record.rewrite
425
427
  if (record.layout !== undefined) y.layout = record.layout
426
428
  if (record.seo !== undefined) y.seo = record.seo
427
- if (record.fetch !== undefined) y.fetch = record.fetch
429
+ // Invert the build's resolution rather than copy it — see fetch-shapes.js.
430
+ if (record.fetch !== undefined) y.fetch = authorableFetch(record.fetch)
428
431
  // `sections:` exists to preserve ORDER and NESTING, which the projected filenames
429
432
  // can't carry (they're `<stableId>.md`, with no numeric prefix). It must not also
430
433
  // decide MEMBERSHIP — and a bare list does: the collector reads a list without
package/src/uwx/site.js CHANGED
@@ -639,7 +639,39 @@ export function isSiteRelativeExtensionUrl(decl) {
639
639
  // The collection DECLARATIONS carried inside site-content `info`-adjacent metadata.
640
640
  // Merges the co-located `collections.yml` (the home for file-based decls) over the
641
641
  // legacy `site.yml::collections` (kept for remote `url:` sources + back-compat).
642
- function collectionsNested(declarations) {
642
+ /**
643
+ * The site's collection DECLARATIONS as `$`-records.
644
+ *
645
+ * ⛔ IDENTITY IS KEYED BY `name`, AND IT HAS TO BE.
646
+ *
647
+ * Every other item on this entity gets its `$uuid` from a map keyed by the file it
648
+ * was projected to — but a collection declaration has no file of its own: they all
649
+ * come from one `collections/collections.yml`. So a path-keyed map has no shape a
650
+ * declaration could occupy, nothing is ever recorded for one, and every push re-sent
651
+ * this whole section uuid-less. The backend refuses that (an all-blank section over
652
+ * stored items would delete every stored row), which is why `push` worked once and
653
+ * every push after it was refused. Measured 2026-08-29; collab framework-backend-812b.
654
+ *
655
+ * ⭐ `name` is the right key and not merely the available one — the backend enforces
656
+ * `unique_field(name, scope: section)` on this section, and it is the join key its
657
+ * `resolve_collection_model` matches and the `/data/{name}.json` serve segment. An
658
+ * author-facing rename is `label`, so the key does not move under either lane.
659
+ *
660
+ * ⛔ NOT `$id`, though it happens to hold the same string. `$id` is a payload-local
661
+ * handle the backend skips on parse and never stores, correlated by submission index
662
+ * rather than by value — keying on it would key on something that exists only inside
663
+ * our own outgoing document.
664
+ *
665
+ * ⚠️ Because `name` IS the identity, renaming a collection is by design
666
+ * indistinguishable from delete-plus-create. Rename one of two and the mix passes;
667
+ * rename every collection at once and the section goes all-blank and is refused.
668
+ * That is the semantics, not a defect.
669
+ *
670
+ * @param {object} declarations resolved collection declarations, keyed by name
671
+ * @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
672
+ * response or a pull. Absent on a first sync, where minting is correct.
673
+ */
674
+ function collectionsNested(declarations, uuids = null) {
643
675
  const out = []
644
676
  for (const [name, d] of Object.entries(declarations)) {
645
677
  const data = {}
@@ -655,7 +687,10 @@ function collectionsNested(declarations) {
655
687
  setIf(data, 'deferred', d.deferred)
656
688
  setIf(data, 'detail_url', d.detailUrl)
657
689
  setIf(data, 'queryable', d.queryable)
658
- out.push(withIdentity(name, { name, ...data }))
690
+ const rec = withIdentity(name, { name, ...data })
691
+ const uuid = uuids?.[name]
692
+ if (typeof uuid === 'string' && uuid) rec.$uuid = uuid
693
+ out.push(rec)
659
694
  }
660
695
  return out
661
696
  }
@@ -860,7 +895,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
860
895
  doc.pages = pages
861
896
  doc.layout_sections = layoutSections
862
897
  doc.extensions = extensionsNested(siteYml)
863
- doc.collections = collectionsNested(colConfig.declarations)
898
+ doc.collections = collectionsNested(colConfig.declarations, opts.collectionUuids)
864
899
  return doc
865
900
  }
866
901
 
@@ -285,7 +285,14 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
285
285
  ...(opts.folderItemUuids ? { itemUuids: opts.folderItemUuids } : {}),
286
286
  })
287
287
 
288
- const siteDoc = includeSite ? await siteProjectToDocument(siteRoot, { sourceLocale }) : null
288
+ // `collectionUuids` identity for the `collections` section, keyed by collection
289
+ // NAME because a declaration has no file of its own (see collectionsNested).
290
+ const siteDoc = includeSite
291
+ ? await siteProjectToDocument(siteRoot, {
292
+ sourceLocale,
293
+ ...(opts.collectionUuids ? { collectionUuids: opts.collectionUuids } : {})
294
+ })
295
+ : null
289
296
  // Deploy-derived `info` fields (e.g. `data_bundle`, the static-data ball URL) are
290
297
  // stamped here — NOT authored in site.yml, so they ride the wire but never project
291
298
  // back on pull (the `info.assets` precedent). They are part of the hashed content,