@uniweb/build 0.24.0 → 0.24.2

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.24.0",
3
+ "version": "0.24.2",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -59,16 +59,17 @@
59
59
  "js-yaml": "^4.1.0",
60
60
  "sharp": "^0.35.3",
61
61
  "yaml": "^2.5.0",
62
- "@uniweb/theming": "^0.1.15",
63
- "@uniweb/content-writer": "^0.3.3",
64
62
  "@uniweb/projections": "^0.3.3",
65
- "@uniweb/schemas": "^0.2.10"
63
+ "@uniweb/semantic-parser": "^1.2.3",
64
+ "@uniweb/schemas": "^0.2.10",
65
+ "@uniweb/theming": "^0.1.15",
66
+ "@uniweb/content-writer": "^0.3.3"
66
67
  },
67
68
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.12.0",
69
- "@uniweb/semantic-parser": "^1.2.2",
70
- "@uniweb/schemas": "^0.2.10",
71
- "@uniweb/content-reader": "^1.2.2"
69
+ "@uniweb/content-reader": "^1.2.3",
70
+ "@uniweb/runtime": "^0.12.2",
71
+ "@uniweb/semantic-parser": "^1.2.3",
72
+ "@uniweb/schemas": "^0.2.10"
72
73
  },
73
74
  "peerDependencies": {
74
75
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -77,7 +78,7 @@
77
78
  "@tailwindcss/vite": "^4.0.0",
78
79
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
79
80
  "vite-plugin-svgr": "^4.0.0",
80
- "@uniweb/core": "^0.10.0"
81
+ "@uniweb/core": "^0.10.1"
81
82
  },
82
83
  "peerDependenciesMeta": {
83
84
  "vite": {
@@ -42,23 +42,33 @@ import {
42
42
  *
43
43
  * Emits to `distDir`:
44
44
  * - `site-content.json` — full content tree, sections always inlined.
45
- * The deploy CLI reads this and ships it as `payload.locales[default]`
46
- * to the worker. Keeping sections inlined is load-bearing: the worker
47
- * re-evaluates split-content from the full payload it receives, and
48
- * emits `_pages/<lang>/<route>.json` itself when split is active.
49
- * If we shipped a stripped manifest (as prerender does for static-host
50
- * bundles), the worker would mis-detect split and serve broken pages.
45
+ * The deploy CLI reads this and ships it as `payload.locales[default]`.
46
+ *
47
+ * Ship FULL sections; never strip them per the split-content rule.
48
+ * A stripped manifest is what prerender emits for static-host bundles,
49
+ * and a consumer that re-derives split content from the full payload
50
+ * would mis-detect split and serve broken pages.
51
+ *
52
+ * ⚠️ This used to justify the rule by stating what the receiving host
53
+ * does with the payload. Removed 2026-08-18: the description was
54
+ * accurate about one deployment and false about another, and both were
55
+ * live at once — so naming "the worker" was wrong even though the
56
+ * sentence was true somewhere. Framework has no host client and no host
57
+ * knowledge (root CLAUDE.md, THE LANE CHAIN); we cannot tell consumers
58
+ * apart and must not write as though we can.
59
+ *
60
+ * ⇒ The rule stands on its own: we do not know which consumers
61
+ * re-derive, so we always ship enough for the ones that do.
51
62
  * - `data/<collection>.json` (+ per-record files for `deferred:`
52
63
  * collections) — same shape `processCollections` produces today.
53
64
  * - `assets/<media>` — processed images / video posters / PDF
54
65
  * thumbnails. Filtered by the deploy CLI to MEDIA only at upload time.
55
66
  *
56
67
  * Does NOT emit:
57
- * - HTML, JS, CSS, source maps, `_importmap/*` — none of these are
58
- * consumed by Uniweb-edge; the worker generates HTML at request time.
59
- * - `_pages/<route>.json` — only meaningful for static-host bundles
60
- * where they're served as static assets. Worker derives them from
61
- * `payload.locales[lang].pages[].sections` server-side.
68
+ * - HTML, JS, CSS, source maps, `_importmap/*` — static-host bundle
69
+ * artifacts; a host on this lane renders pages itself.
70
+ * - `_pages/<route>.json` — only meaningful for static-host bundles,
71
+ * where they are served as files.
62
72
  * - `sitemap.xml`, `robots.txt` — static-host bundle territory. Note these
63
73
  * are simply absent from Uniweb-hosted sites today: nothing in platform
64
74
  * generates them, at publish time or at request time.
@@ -193,11 +203,38 @@ export async function buildSiteData({
193
203
  delete finalContent.hasExplicitPoster
194
204
  delete finalContent.hasExplicitPreview
195
205
 
206
+ // ⛔ `config.icons` is a HOST-owned slot on this lane — drop it.
207
+ //
208
+ // This payload only ever goes to a backend (link mode is what `uniweb deploy`
209
+ // runs), and `config` is site.yml spread whole, so an author's
210
+ // `icons.cdnUrl` would otherwise ride along and land in a slot the host is
211
+ // supposed to fill. The icon base is a property of the DEPLOYMENT, identical
212
+ // for every site a given backend serves — not something one site gets to
213
+ // choose [Diego, 2026-08-17].
214
+ //
215
+ // The reason it cannot be a site's call is that the base and the NAMESPACE
216
+ // are coupled: icon filenames are minted by whoever built the corpus, so
217
+ // content carrying one host's names 404s wholesale against a different base.
218
+ // A site-level override here is a broken site, not a preference.
219
+ //
220
+ // The sync lane never had this exposure — `uwx/site.js` builds `info.*` from
221
+ // a key-by-key allowlist with no `icons` entry. This makes the two agree.
222
+ // `site.yml::icons` stays fully live on the bundled/static lanes, which have
223
+ // no host to ask.
224
+ //
225
+ // Rebuilt rather than mutated: `finalContent` is sometimes `siteContent`
226
+ // itself, and `config` is shared by reference through the shallow copies
227
+ // above.
228
+ if (finalContent.config?.icons !== undefined) {
229
+ const { icons: _hostOwnedIcons, ...configWithoutIcons } = finalContent.config
230
+ finalContent = { ...finalContent, config: configWithoutIcons }
231
+ }
232
+
196
233
  // 4. Write `dist/site-content.json` with FULL sections inlined.
197
234
  // Important: do NOT strip sections per the split-content rule here.
198
- // The link-mode deploy ships full content; the worker re-evaluates
199
- // split + emits `_pages/<lang>/<route>.json` itself. Stripping at
200
- // this stage would silently break split-mode sites in production.
235
+ // Stripping would silently break split-mode sites on any consumer that
236
+ // re-derives split content from the full payload. See the header for
237
+ // why this no longer names one.
201
238
  const contentPath = join(resolvedDistDir, 'site-content.json')
202
239
  await writeFile(contentPath, JSON.stringify(finalContent, null, 2))
203
240
 
@@ -1396,6 +1396,16 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1396
1396
  // Knowledge page — content feeds AI pipeline instead of (or in addition to) rendering
1397
1397
  ...(pageConfig.knowledge != null ? { knowledge: pageConfig.knowledge } : {}),
1398
1398
 
1399
+ // Per-page section instrumentation opt-in. ⛔ PAGE-LEVEL ONLY, and it must
1400
+ // stay that way: a site-wide form would emit one event per section on every
1401
+ // page, whose cardinality a counter-based collector cannot store (measured:
1402
+ // ~160 distinct values against a 50-value cap folds ~70% into `(other)`).
1403
+ // Deliberately NOT part of the site → page → section cascade that
1404
+ // `prerender:` uses, even though that is the local convention — following it
1405
+ // would re-create the unscoped mode through the front door.
1406
+ // the tracking design, §10b.
1407
+ ...(pageConfig.trackSections != null ? { trackSections: pageConfig.trackSections } : {}),
1408
+
1399
1409
  // Layout options (named layout + per-page overrides)
1400
1410
  layout: {
1401
1411
  ...(resolvedLayoutName ? { name: resolvedLayoutName } : {}),
@@ -2357,7 +2367,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2357
2367
  for (const { message } of langValidation.warnings) {
2358
2368
  console.warn(`[content-collector] ${message}`)
2359
2369
  }
2360
- // Publish filter (kb/framework/build/uwx-format.md "Per-locale publish
2370
+ // Publish filter (the sync format's "Per-locale publish
2361
2371
  // readiness"): on published build paths, only the publishable intersection
2362
2372
  // (publishLanguages ∩ languages; absent field = all declared) ships — in
2363
2373
  // the embedded languages list and in route translations. Dev keeps the
@@ -2411,7 +2421,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2411
2421
  // needs no line at all on this lane (`config` spreads all of site.yml) and one
2412
2422
  // line on the sync lane, so it reaches BOTH. The separate file could only ever
2413
2423
  // reach this one, which is what made an authored persona vanish on hosted
2414
- // sites. See kb/framework/architecture/assistant-config.md.
2424
+ // sites.
2415
2425
  ...(routeTranslations
2416
2426
  ? { i18n: { ...(siteConfig.i18n || {}), routeTranslations } }
2417
2427
  : {}),
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `assets.json` — the site's committed map from a local asset path to the
3
+ * backend's content-addressed id.
4
+ *
5
+ * ## Why it is COMMITTED, and why that is the whole point
6
+ *
7
+ * The same map used to be built on every push and thrown away
8
+ * (`assetsByLocalUrl`, in memory, consumed by the content rewrite and dropped).
9
+ * Rebuilt per push it is a private detail; committed it is a **log every machine
10
+ * collaborating on this site can read**:
11
+ *
12
+ * - a teammate who clones knows which bytes the project expects to exist and
13
+ * can fetch the ones they lack, instead of discovering a missing image at
14
+ * render;
15
+ * - `pull` can put an asset back at **the path its author wrote**, rather than
16
+ * inventing one — without this, a push turns `/images/hero.png` into an
17
+ * opaque id and nothing remembers it was ever called that;
18
+ * - a re-push of unchanged media is visibly a no-op rather than a silent one.
19
+ *
20
+ * ⇒ It is project state, not cache. `.uniweb/` is gitignored in both scaffolded
21
+ * templates, which is why this does not live there.
22
+ *
23
+ * ## ⛔ Diff stability is a hard requirement, not tidiness
24
+ *
25
+ * A committed file that reorders itself produces a spurious diff on every push
26
+ * and trains people to stop reading it — at which point a real change to what a
27
+ * site ships passes unnoticed. Keys are therefore **sorted**, the shape is flat,
28
+ * and the writer is a no-op when nothing changed (it does not rewrite an
29
+ * identical file, so `git status` stays clean on a push that moved no assets).
30
+ *
31
+ * ## What it deliberately does NOT hold
32
+ *
33
+ * ⛔ **No serve URL.** A URL is a host's route layout, and storing one here would
34
+ * re-create — in a committed file, on every machine — exactly the coupling that
35
+ * deleting `buildAssetUrl` removed from this CLI. The id plus the host's
36
+ * `config.assets.url` pattern is the whole address, and only the host owns the
37
+ * second half.
38
+ *
39
+ * ⛔ **No mime or size.** The store validates those and they are its to change;
40
+ * a second copy here is a second thing to disagree.
41
+ */
42
+
43
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
44
+ import { ASSET_SLOTS } from '@uniweb/semantic-parser'
45
+ import { join } from 'node:path'
46
+
47
+ export const ASSET_MAP_FILE = 'assets.json'
48
+
49
+ /** Current on-disk shape. Bumped only for a breaking layout change. */
50
+ const VERSION = 1
51
+
52
+ const mapPath = (siteDir) => join(siteDir, ASSET_MAP_FILE)
53
+
54
+ /**
55
+ * Read the site's asset map. A missing, unreadable or malformed file reads as
56
+ * empty rather than throwing: the map is an accelerator and a record, and a
57
+ * corrupt one must never be the reason a push fails. The next write repairs it.
58
+ *
59
+ * @param {string} siteDir
60
+ * @returns {Record<string, { id: string, ext: string }>} local ref → identity
61
+ */
62
+ export function readAssetMap(siteDir) {
63
+ const p = mapPath(siteDir)
64
+ if (!existsSync(p)) return {}
65
+ try {
66
+ const parsed = JSON.parse(readFileSync(p, 'utf8'))
67
+ const assets = parsed?.assets
68
+ if (!assets || typeof assets !== 'object') return {}
69
+ const out = {}
70
+ for (const [ref, v] of Object.entries(assets)) {
71
+ if (v && typeof v.id === 'string' && v.id) {
72
+ out[ref] = { id: v.id, ext: typeof v.ext === 'string' ? v.ext : '' }
73
+ }
74
+ }
75
+ return out
76
+ } catch {
77
+ return {}
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Merge `entries` into the site's map and write it if anything changed.
83
+ *
84
+ * MERGE, not replace: a push carries only the refs that page's content touched,
85
+ * so replacing would drop every asset this run happened not to mention — which
86
+ * is most of them on a partial push. An entry whose id changed is overwritten
87
+ * (the bytes at that path changed); an identical entry is left alone.
88
+ *
89
+ * @param {string} siteDir
90
+ * @param {Record<string, { id: string, ext: string }>} entries
91
+ * @returns {{ added: string[], changed: string[], written: boolean }}
92
+ */
93
+ export function updateAssetMap(siteDir, entries) {
94
+ const prior = readAssetMap(siteDir)
95
+ const added = []
96
+ const changed = []
97
+
98
+ for (const [ref, v] of Object.entries(entries || {})) {
99
+ if (!v?.id) continue
100
+ const was = prior[ref]
101
+ if (!was) added.push(ref)
102
+ else if (was.id !== v.id) changed.push(ref)
103
+ else continue
104
+ prior[ref] = { id: v.id, ext: v.ext || '' }
105
+ }
106
+
107
+ if (!added.length && !changed.length) return { added, changed, written: false }
108
+
109
+ // Sorted keys + a trailing newline: a committed file that reorders itself
110
+ // produces a diff on every push and teaches people to skip reading it.
111
+ const assets = {}
112
+ for (const ref of Object.keys(prior).sort()) assets[ref] = prior[ref]
113
+ writeFileSync(
114
+ mapPath(siteDir),
115
+ JSON.stringify({ version: VERSION, assets }, null, 2) + '\n'
116
+ )
117
+ return { added, changed, written: true }
118
+ }
119
+
120
+ /**
121
+ * The local ref a given asset id is known at, or null.
122
+ *
123
+ * This is the direction `pull` needs and the reason the map is worth
124
+ * committing: stored content carries an id, and only this can say the author
125
+ * called it `/images/hero.png`. Without it a pull must invent a path.
126
+ *
127
+ * @param {Record<string, { id: string, ext: string }>} map
128
+ * @param {string} id
129
+ * @returns {string|null}
130
+ */
131
+ export function refForAssetId(map, id) {
132
+ if (!id) return null
133
+ for (const [ref, v] of Object.entries(map || {})) {
134
+ if (v.id === id) return ref
135
+ }
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * Restore authored asset paths on a document being projected back to files.
141
+ *
142
+ * The inverse of the push-side stamp in `sync-package.js`: push replaces a local
143
+ * ref with the host's serve URL and stamps `assetId` beside it; this reads the
144
+ * id back and puts the author's own path where the URL is.
145
+ *
146
+ * ⭐ **This is the reason the map is committed.** Stored content carries an id
147
+ * and a URL, and neither says the author called it `/images/hero.png`. Without
148
+ * this, a dev who pulls a site they pushed yesterday finds every image rewritten
149
+ * to a backend route — their source mangled by a round trip that touched
150
+ * nothing. With it, a push/pull cycle is a fixed point on the paths they wrote.
151
+ *
152
+ * ⛔ An id the map does not know is LEFT ALONE, deliberately. That is an asset
153
+ * this project has never held — authored in the app, or pushed from another
154
+ * machine whose map entry has not arrived — and the honest projection is the URL
155
+ * that works, not a local path to a file that is not there. Filling those in is
156
+ * the download's job, not this one.
157
+ *
158
+ * `assetId` itself is not removed: it is not a markdown attribute, so the
159
+ * serializer drops it on the way to disk, and leaving it lets a caller project
160
+ * the same document twice without the second pass losing identity.
161
+ *
162
+ * @param {object} document - the site-content document (mutated in place)
163
+ * @param {Record<string, { id: string, ext: string }>} map - readAssetMap()
164
+ * @returns {{ restored: number, unknown: number }}
165
+ */
166
+ export function restoreAssetRefs(document, map) {
167
+ const byId = new Map()
168
+ for (const [ref, v] of Object.entries(map || {})) {
169
+ if (v?.id && !byId.has(v.id)) byId.set(v.id, ref)
170
+ }
171
+ const stats = { restored: 0, unknown: 0 }
172
+ if (!byId.size) return stats
173
+
174
+ const visit = (node) => {
175
+ if (Array.isArray(node)) return node.forEach(visit)
176
+ if (!node || typeof node !== 'object') return
177
+ // Every slot: a poster is the one asset a round trip would still mangle if
178
+ // only the primary reference were restored.
179
+ for (const slot of ASSET_SLOTS) {
180
+ const id = node[slot.id]
181
+ if (typeof id !== 'string' || !id) continue
182
+ const ref = byId.get(id)
183
+ if (!ref) { stats.unknown++; continue }
184
+ const urlAttr = slot.urls.find((k) => typeof node[k] === 'string') || slot.urls[0]
185
+ node[urlAttr] = ref
186
+ stats.restored++
187
+ }
188
+ for (const v of Object.values(node)) visit(v)
189
+ }
190
+ visit(document)
191
+ return stats
192
+ }
@@ -493,10 +493,16 @@ export async function buildCollectionEntities(siteRoot, opts = {}) {
493
493
  // soft skip — the collection is delivery-only, not a sync target. Only an
494
494
  // EXPLICIT schema/model the author asked for is a hard error.
495
495
  if (!decl.schemaExplicit) {
496
- warnings.push(
497
- `${name}: no data schema "${modelName}" (subfolder-name default)not synced`
498
- )
499
- schemaless.push({ name })
496
+ // ⛔ Deliberately NOT a `warnings` string. This is a product decision the
497
+ // author is making entities or static files and it needs to be
498
+ // reported at a prominence a prose warning cannot carry. Callers get the
499
+ // structured entry and say it themselves (`cli/src/commands/{publish,push}.js`).
500
+ //
501
+ // It used to push `"… — not synced"`, printed dim among everything else.
502
+ // That was misleading in the expensive direction: the data IS delivered,
503
+ // as static files. An author read "not synced" as "my data did not
504
+ // upload" — or skimmed it — and either way could not act on it.
505
+ schemaless.push({ name, model: modelName })
500
506
  continue
501
507
  }
502
508
  throw new Error(
package/src/uwx/index.js CHANGED
@@ -72,6 +72,13 @@ export {
72
72
  localeFilePath,
73
73
  } from './locale-sync.js'
74
74
  export { emitSyncPackages } from './sync-package.js'
75
+ export {
76
+ readAssetMap,
77
+ updateAssetMap,
78
+ refForAssetId,
79
+ restoreAssetRefs,
80
+ ASSET_MAP_FILE,
81
+ } from './asset-map.js'
75
82
  export {
76
83
  diffSiteUnits,
77
84
  describeSiteDiff,
@@ -33,6 +33,7 @@
33
33
  // the i18n pipeline). Absent `info` keys are left untouched on disk.
34
34
 
35
35
  import { join, relative, extname, basename } from 'node:path'
36
+ import { readAssetMap, restoreAssetRefs } from './asset-map.js'
36
37
  import { readFileSync, existsSync, unlinkSync, renameSync, rmSync, readdirSync, statSync } from 'node:fs'
37
38
  import { createHash } from 'node:crypto'
38
39
  import yaml from 'js-yaml'
@@ -373,7 +374,7 @@ export function pageSectionsToFiles({ pageDir, pageSections, ctx, pageContext })
373
374
  // and preserved. Keep in sync with pageRecordToYml below.
374
375
  const PAGE_YML_MANAGED_KEYS = new Set([
375
376
  'id', 'title', 'description', 'label', 'keywords', 'index', 'hidden',
376
- 'hideIn', 'knowledge', 'redirect', 'rewrite', 'layout', 'seo',
377
+ 'hideIn', 'knowledge', 'trackSections', 'redirect', 'rewrite', 'layout', 'seo',
377
378
  'fetch', 'sections',
378
379
  ])
379
380
 
@@ -396,6 +397,12 @@ function pageRecordToYml(record, sectionsArray, sourceLocale) {
396
397
  if (record.hidden !== undefined) y.hidden = record.hidden
397
398
  if (record.hide_in !== undefined) y.hideIn = record.hide_in
398
399
  if (record.knowledge !== undefined) y.knowledge = record.knowledge
400
+ // Wire snake_case → authored camelCase, the same crossing `hide_in` → `hideIn`
401
+ // makes two lines up. ⛔ Both directions or neither: a page prop that pushes
402
+ // and does not pull is WORSE than one that does neither, because the author's
403
+ // flag is silently removed from their `page.yml` the first time they run
404
+ // `uniweb pull`.
405
+ if (record.track_sections !== undefined) y.trackSections = record.track_sections
399
406
  if (record.redirect !== undefined) y.redirect = record.redirect
400
407
  if (record.rewrite !== undefined) y.rewrite = record.rewrite
401
408
  if (record.layout !== undefined) y.layout = record.layout
@@ -620,12 +627,19 @@ function projectLayout(layoutSections, layoutBaseDir, report, prune, ctx) {
620
627
  * @returns {{ config: object, collections: object, locales: object, pages: string[], sections: string[], layout: string[], deleted: string[], renamed: object[] }}
621
628
  */
622
629
  export function siteContentDocumentToProject({ document, siteRoot, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, prune = false }) {
623
- const report = { config: null, collections: null, locales: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
630
+ const report = { config: null, collections: null, locales: null, assets: null, pages: [], sections: [], layout: [], deleted: [], renamed: [] }
624
631
 
625
632
  // Collects target-locale translations of localized scalars as they're projected;
626
633
  // flushed to locales/{locale}.json at the end (the manifest stays derivable).
627
634
  const collector = createTranslationCollector(sourceLocale)
628
635
 
636
+ // Put the author's own asset paths back before anything is serialized. Stored
637
+ // content carries an id and a serve URL; only the committed map knows the ref
638
+ // the author wrote. Without this a push/pull cycle rewrites every image in a
639
+ // developer's source to a backend route — a mangling of files they own, by a
640
+ // round trip that changed nothing.
641
+ report.assets = restoreAssetRefs(document, readAssetMap(siteRoot))
642
+
629
643
  report.config = siteInfoToConfig({ document, siteRoot, sourceLocale, collector })
630
644
  report.collections = declarationsToCollectionsYml({ document, siteRoot })
631
645
 
package/src/uwx/site.js CHANGED
@@ -206,6 +206,16 @@ function buildPageData(config, ctx) {
206
206
  // equivalently by walking the page tree). A reader that tests this field alone
207
207
  // honours the branch root and silently misses every child.
208
208
  setIf(data, 'knowledge', config.knowledge)
209
+ // Per-page section instrumentation opt-in. ⭐ Authored camelCase → wire
210
+ // snake_case, the same crossing `hideIn` → `hide_in` already makes; the
211
+ // backend's field list is entirely snake_case and stays that way.
212
+ // ⛔ It MUST cross: without this line the flag works on `--bundle`/`--link`
213
+ // and is silently ignored on a backend-hosted site, where page config comes
214
+ // from the backend's projection — i.e. it would fail on the one lane the
215
+ // feature is sold on, with no instrument able to say why.
216
+ // ⚠️ Declared backend-side FIRST (`track_sections`, generation 7) — an
217
+ // undeclared field refuses the whole push, not the field.
218
+ setIf(data, 'track_sections', config.trackSections)
209
219
  setIf(data, 'redirect', config.redirect)
210
220
  setIf(data, 'rewrite', config.rewrite)
211
221
  setIf(data, 'layout', config.layout)
@@ -282,7 +292,7 @@ const DYNAMIC_RE = /^\[(.+)\]$/
282
292
  // The flat `siteProjectToEntity` above emits `items[]` with positional
283
293
  // `parent_path` tuple-chains (the register lane, package.js). This lane emits the
284
294
  // section-keyed `$`-document the backend's @uniweb/site-content Model actually
285
- // declares (apps/uniweb-rs/.../system-models/site-content.fixture.yaml) and that
295
+ // declares (the backend's site-content system-model fixture) and that
286
296
  // docs/reference/entity-content.md specifies:
287
297
  //
288
298
  // - `page_sections` is a CHILD section of `pages` → it rides as an INLINE FIELD
@@ -716,8 +726,16 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
716
726
  // (registry ref / URL / local path), the round-trip source of truth.
717
727
  info.foundation = siteYml.foundation
718
728
  setIf(info, 'base', siteYml.base)
719
- // favicon — a verbatim URL/path string (the kit resolves it, like other media
720
- // refs). `assets` is a build-DERIVED upload manifest, not authored config, so it
729
+ // favicon — a verbatim URL/path string. ⚠️ This comment claimed "the kit
730
+ // resolves it, like other media refs" until 2026-08-17; measured, `favicon`
731
+ // appears nowhere in `kit/src` or `runtime/src`. The real consumer is
732
+ // `build/src/site/plugin.js`, which injects a `<link>` into `index.html` at
733
+ // BUILD time — from `config.favicon` verbatim, else by auto-detecting
734
+ // `public/favicon.{svg,ico,png}`. So it is a build-time injection, not a
735
+ // runtime resolve, and anything giving the favicon a resolvable asset
736
+ // reference has to act there rather than in the runtime.
737
+ //
738
+ // `assets` is a build-DERIVED upload manifest, not authored config, so it
721
739
  // is never produced from / projected to the site files.
722
740
  setIf(info, 'favicon', siteYml.favicon)
723
741
  // Site-level SEO/social metadata — the same shape as page.yml's `seo:` + the
@@ -757,7 +775,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
757
775
  // file, which needed a bespoke line in each lane and got one in only the
758
776
  // bundle lane — so an authored persona never reached a hosted site at all.
759
777
  // A key inside site.yml cannot repeat that, because only this lane needs a
760
- // line. (kb/framework/architecture/assistant-config.md)
778
+ // line.
761
779
  //
762
780
  // ⛔ Credentials are stripped, not trusted — see `stripCredentials`.
763
781
  setIf(info, 'assistant', stripCredentials(siteYml.assistant, 'assistant'))
@@ -773,7 +791,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
773
791
  // strip only reaches a KEYED FIELD: a key embedded in the endpoint URL itself
774
792
  // (`https://collector/e?key=…`) is invisible here and is disclosed. The host's
775
793
  // secret store is the only right home either way.
776
- // (kb/framework/plans/tracking.md)
794
+
777
795
  setIf(info, 'tracking', stripCredentials(siteYml.tracking, 'tracking'))
778
796
  setIf(info, 'paths', siteYml.paths)
779
797
  setIf(info, 'data', siteYml.data ?? siteYml.fetch)
@@ -20,6 +20,7 @@
20
20
  // lane (the idempotent no-op).
21
21
 
22
22
  import { buildCollectionEntities, entityContentHash } from './collections.js'
23
+ import { ASSET_SLOTS } from '@uniweb/semantic-parser'
23
24
  import { buildFolderEntity } from './folder.js'
24
25
  import { siteProjectToDocument } from './site.js'
25
26
  import { stampUnitUuids, collectUnitUuids } from './site-diff.js'
@@ -110,22 +111,83 @@ function walkEntityAssets(node, visitor) {
110
111
  }
111
112
 
112
113
  // In-place: replace every local-asset-path string the map covers with its serve
113
- // URL. A ref the map omits (upload failed/skipped) is left untouched — never a
114
- // broken URL. Returns the (mutated) node.
115
- function rewriteEntityAssets(node, map) {
114
+ // URL, and stamp the asset's IDENTITY beside it. A ref the map omits (upload
115
+ // failed/skipped) is left untouched — never a broken URL. Returns the (mutated)
116
+ // node.
117
+ //
118
+ // ⭐ `ids` writes `assetId`/`assetExt` ALONGSIDE the URL rather than instead of
119
+ // it, and the "alongside" is the whole safety property of the interim:
120
+ //
121
+ // - **the id is what survives.** A URL is a host's route layout frozen into
122
+ // content that outlives it; an id is re-resolved on every render, so a host
123
+ // that moves its assets costs a config edit rather than a migration.
124
+ // - **the URL is what RENDERS today.** With the URL still there, a resolver
125
+ // that cannot resolve the id falls through and renders exactly as before.
126
+ //
127
+ // ⚠️ **This bullet used to justify itself with "no deployment emits
128
+ // `config.assets.url` yet". FALSIFIED 2026-08-18** by the backend lane:
129
+ // `serve` publishes the pattern **unconditionally**, falling back to the
130
+ // direct form, so a deployment with no asset storage emits the honest
131
+ // `/gateway/asset/dist/{id}/base.{ext}` rather than nothing (measured by
132
+ // them on a running daemon, not read off a type). ⛔ Scoped to deployments
133
+ // running code from 2026-08-17 or later; an older one emits nothing, and
134
+ // absent stays absent. A present-tense negative about someone else's
135
+ // deployments is the claim nothing in this repo can ever contradict — it
136
+ // was true when written, and nothing here changed when it stopped being.
137
+ //
138
+ // ⛔ **That does NOT license dropping the URL, and the reason is not
139
+ // coverage.** An earlier version of this note named coverage as the
140
+ // governor; it was only ever a gate. Dropping the `src` companion was
141
+ // **withdrawn as a goal** on 2026-08-17 on CONSUMER grounds: the editor
142
+ // canvas renders `attrs.url || attrs.src` and reads `assetId` nowhere, so
143
+ // an id-only node shows an `<img>` with no source *(measured by the
144
+ // frontend lane — their surface, their finding)*. Durability comes from
145
+ // every consumer PREFERRING the id, which each can do on its own schedule;
146
+ // dropping `src` only forces that at the cost of a migration window across
147
+ // two consumer chains where images blank.
148
+ //
149
+ // ⇒ **Keep writing both.** See `kb/framework/content/asset-url-resolution.md`
150
+ // item 13 — and re-read it there rather than re-deriving the answer here.
151
+ //
152
+ // ⇒ Writing both means content authored now stays correct whichever order the
153
+ // halves arrive in — the same reason the app writes both. The URL is dropped
154
+ // only once every deployment declares a pattern.
155
+ //
156
+ // Identity is stamped on the OBJECT carrying the reference, which covers a
157
+ // ProseMirror image node's attrs (`{src, alt, …}`) and a section background's
158
+ // media object (`{image: {src}}`) with one rule — the two shapes framework
159
+ // resolves, reached through the same walk.
160
+ function rewriteEntityAssets(node, map, ids) {
116
161
  if (Array.isArray(node)) {
117
162
  for (let i = 0; i < node.length; i++) {
118
163
  const v = node[i]
119
164
  if (typeof v === 'string') { if (map[v]) node[i] = map[v] }
120
- else rewriteEntityAssets(v, map)
165
+ else rewriteEntityAssets(v, map, ids)
121
166
  }
122
167
  return node
123
168
  }
124
169
  if (node && typeof node === 'object') {
170
+ // Stamp BEFORE the string swap below, while the reference is still the
171
+ // local ref the ids map is keyed by.
172
+ if (ids) {
173
+ // Every asset slot, not just the primary: a video's `poster` and a
174
+ // document's `preview` are assets like any other, and each has identity
175
+ // attrs naming which reference they belong to (ASSET_SLOTS).
176
+ for (const slot of ASSET_SLOTS) {
177
+ const ref = slot.urls
178
+ .map((k) => (typeof node[k] === 'string' ? node[k] : null))
179
+ .find(Boolean)
180
+ const identity = ref ? ids[ref] : null
181
+ if (identity?.id) {
182
+ node[slot.id] = identity.id
183
+ if (identity.ext) node[slot.ext] = identity.ext
184
+ }
185
+ }
186
+ }
125
187
  for (const key of Object.keys(node)) {
126
188
  const v = node[key]
127
189
  if (typeof v === 'string') { if (map[v]) node[key] = map[v] }
128
- else rewriteEntityAssets(v, map)
190
+ else rewriteEntityAssets(v, map, ids)
129
191
  }
130
192
  }
131
193
  return node
@@ -165,6 +227,8 @@ function rewriteEntityAssets(node, map) {
165
227
  * `publish` fills this for the site's LOCAL extensions after releasing them;
166
228
  * the parallel of `info.foundation`, and needed because `extensions` is a
167
229
  * sibling of `info` rather than a field in it.
230
+ * @param {Object<string,{id:string,ext:string}>} [opts.assetIds] - local asset ref →
231
+ * backend identity, stamped as `assetId`/`assetExt` BESIDE the URL
168
232
  * @param {Object<string,string>} [opts.assetRewrite] - map of local asset ref →
169
233
  * backend serve URL; rewrites the entities' media refs before push (the
170
234
  * deploy's 2nd emit). Absent → no rewrite (the f225 sync path is unchanged).
@@ -173,7 +237,7 @@ function rewriteEntityAssets(node, map) {
173
237
  * siteContent: { buffer, entityCount, index, models }|null,
174
238
  * collections: { buffer, entityCount, index, models }|null,
175
239
  * hashes: Object<string,string>, warnings: string[], skipped: number,
176
- * schemaless: Array<{name: string}>, localAssets: string[] }>}
240
+ * schemaless: Array<{name: string, model: string}>, localAssets: string[] }>}
177
241
  * `schemaless` lists collections that resolved no data schema (soft-skipped from
178
242
  * the sync) — the composite deploy delivers these statically via the data ball.
179
243
  * `localAssets` lists the site-root local media refs (`/images/x.png`) the deploy
@@ -263,9 +327,11 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
263
327
  // every non-deploy caller, so the f225 sync path is byte-identical without it.
264
328
  const assetRewrite =
265
329
  opts.assetRewrite && typeof opts.assetRewrite === 'object' ? opts.assetRewrite : null
330
+ const assetIds =
331
+ opts.assetIds && typeof opts.assetIds === 'object' ? opts.assetIds : null
266
332
  if (assetRewrite) {
267
- if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite)
268
- for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite)
333
+ if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds)
334
+ for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite, assetIds)
269
335
  }
270
336
  // Collect the site-root local refs the deploy must upload (`/images/x.png`).
271
337
  // Co-located refs (`./x`, `../x`) need the source `.md` location to resolve — the
@@ -60,10 +60,18 @@ let _buildingSSRBundle = false
60
60
  * The pin is a **compatibility floor**, not a selector, and it cannot be a
61
61
  * selector: a site loads a primary foundation plus N extensions, each emitting
62
62
  * its own pin, while a site has exactly one runtime — pins are plural, the
63
- * choice is singular. The selector is `site.yml::runtime`. The pin's use is
64
- * VALIDATION is a site's runtime at or above max() of every loaded
65
- * foundation's floor? which belongs wherever all of those foundations are
66
- * held, not here: this build sees one foundation.
63
+ * choice is singular. The pin's use is VALIDATION is a site's runtime at or
64
+ * above max() of every loaded foundation's floor? which belongs wherever all
65
+ * of those foundations are held, not here: this build sees one foundation.
66
+ *
67
+ * ⚠️ **This used to say "the selector is `site.yml::runtime`". It is not.** That
68
+ * key is an operator-level override, not the authoring surface: a link-mode site
69
+ * is CODELESS, so it has nothing that binds to a runtime version and no basis for
70
+ * an opinion about one. The selector is the backend's resolution (an explicit pin
71
+ * → the site's current → a deployment default → newest installed). What a
72
+ * foundation declares here is the CONSTRAINT on that choice, never the choice.
73
+ * the site/foundation/runtime model, § "who gets to say
74
+ * whether a site accepts a newer runtime" is the authority.
67
75
  *
68
76
  * Reads the resolved version from the foundation's node_modules/@uniweb/
69
77
  * runtime/package.json so the pin reflects what was actually linked at
@@ -80,6 +88,27 @@ let _buildingSSRBundle = false
80
88
  * foundation's own package.json is recorded alongside the runtime version,
81
89
  * declaring the author's intent for the validation above.
82
90
  *
91
+ * ⛔ **`policy` REACHES NOBODY TODAY — it is written here and read by nothing.**
92
+ * `readRuntimePin()` (`cli/src/utils/code-upload.js`) returns only `.runtime`, so
93
+ * `register` carries the floor as `info.runtime` and the policy is dropped. A
94
+ * foundation author who sets `uniweb.runtimePolicy` gets it emitted into `dist/`
95
+ * and silently discarded.
96
+ *
97
+ * ⚖️ **This is a field with standing and no consumer — NOT a mistake to delete.**
98
+ * The architecture assigns this declaration to the foundation on principle: each
99
+ * version policy is declared by the party whose code binds to the thing being
100
+ * updated, and the foundation's JS is what links against the runtime's React and
101
+ * core. So this is the designated home for the answer; nothing has asked for the
102
+ * answer yet.
103
+ *
104
+ * ⇒ **The event that gives it a consumer** (agreed with the backend, 2026-08-16):
105
+ * when the runtime declares what it supplies — replacing today's `>=` version
106
+ * compare, which is a proxy that holds only while the runtime's version number
107
+ * tracks its externals contract — **or** when the publish path starts consulting
108
+ * floors at all, whichever lands first. Ask again then; until one of those, delivering
109
+ * `policy` would add a field compared against a number already known to be a
110
+ * stand-in. **Do not wire it, and do not remove it, without re-opening that.**
111
+ *
83
112
  * @param {string} outDir - dist/ directory to write to.
84
113
  * @param {string} projectRoot - foundation project root (where package.json lives).
85
114
  */
@@ -171,6 +200,15 @@ async function emitRuntimePin(outDir, projectRoot) {
171
200
  const pinPath = join(outDir, 'runtime-pin.json')
172
201
  await writeFile(pinPath, JSON.stringify(pin, null, 2) + '\n', 'utf-8')
173
202
  console.log(`Generated runtime-pin.json (runtime ${runtimeVersion}${policy ? `, policy ${policy}` : ''})`)
203
+ // Said HERE, not only in the header above: this line is where a foundation
204
+ // author meets the field, and reading "policy X" next to a floor that IS
205
+ // delivered implies both travel. Only the floor does.
206
+ if (policy) {
207
+ console.log(
208
+ ` note: runtimePolicy "${policy}" is recorded but not yet delivered — nothing consumes it today.\n` +
209
+ ` The floor (runtime ${runtimeVersion}) does travel, as info.runtime at register.`
210
+ )
211
+ }
174
212
  }
175
213
 
176
214
  /**