@uniweb/build 0.24.0 → 0.24.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 +9 -8
- package/src/site/build-site-data.js +51 -14
- package/src/site/content-collector.js +12 -2
- package/src/uwx/asset-map.js +192 -0
- package/src/uwx/index.js +7 -0
- package/src/uwx/site-project.js +16 -2
- package/src/uwx/site.js +23 -5
- package/src/uwx/sync-package.js +49 -7
- package/src/vite-foundation-plugin.js +42 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.24.
|
|
3
|
+
"version": "0.24.1",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -60,15 +60,16 @@
|
|
|
60
60
|
"sharp": "^0.35.3",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
62
|
"@uniweb/theming": "^0.1.15",
|
|
63
|
-
"@uniweb/content-writer": "^0.3.3",
|
|
64
63
|
"@uniweb/projections": "^0.3.3",
|
|
65
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/semantic-parser": "^1.2.3",
|
|
65
|
+
"@uniweb/schemas": "^0.2.10",
|
|
66
|
+
"@uniweb/content-writer": "^0.3.3"
|
|
66
67
|
},
|
|
67
68
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/
|
|
69
|
-
"@uniweb/
|
|
70
|
-
"@uniweb/
|
|
71
|
-
"@uniweb/
|
|
69
|
+
"@uniweb/content-reader": "^1.2.3",
|
|
70
|
+
"@uniweb/runtime": "^0.12.1",
|
|
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.
|
|
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
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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/*` —
|
|
58
|
-
*
|
|
59
|
-
* - `_pages/<route>.json` — only meaningful for static-host bundles
|
|
60
|
-
* where they
|
|
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
|
-
//
|
|
199
|
-
// split
|
|
200
|
-
// this
|
|
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 (
|
|
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.
|
|
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
|
+
}
|
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,
|
package/src/uwx/site-project.js
CHANGED
|
@@ -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 (
|
|
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
|
|
720
|
-
//
|
|
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.
|
|
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
|
-
|
|
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)
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -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,59 @@ 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
|
|
114
|
-
// broken URL. Returns the (mutated)
|
|
115
|
-
|
|
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.** No deployment emits `config.assets.url`
|
|
125
|
+
// yet, so a resolver handed an id alone would resolve nothing. With the URL
|
|
126
|
+
// still there it falls through and renders exactly as before.
|
|
127
|
+
//
|
|
128
|
+
// ⇒ Writing both means content authored now stays correct whichever order the
|
|
129
|
+
// halves arrive in — the same reason the app writes both. The URL is dropped
|
|
130
|
+
// only once every deployment declares a pattern.
|
|
131
|
+
//
|
|
132
|
+
// Identity is stamped on the OBJECT carrying the reference, which covers a
|
|
133
|
+
// ProseMirror image node's attrs (`{src, alt, …}`) and a section background's
|
|
134
|
+
// media object (`{image: {src}}`) with one rule — the two shapes framework
|
|
135
|
+
// resolves, reached through the same walk.
|
|
136
|
+
function rewriteEntityAssets(node, map, ids) {
|
|
116
137
|
if (Array.isArray(node)) {
|
|
117
138
|
for (let i = 0; i < node.length; i++) {
|
|
118
139
|
const v = node[i]
|
|
119
140
|
if (typeof v === 'string') { if (map[v]) node[i] = map[v] }
|
|
120
|
-
else rewriteEntityAssets(v, map)
|
|
141
|
+
else rewriteEntityAssets(v, map, ids)
|
|
121
142
|
}
|
|
122
143
|
return node
|
|
123
144
|
}
|
|
124
145
|
if (node && typeof node === 'object') {
|
|
146
|
+
// Stamp BEFORE the string swap below, while the reference is still the
|
|
147
|
+
// local ref the ids map is keyed by.
|
|
148
|
+
if (ids) {
|
|
149
|
+
// Every asset slot, not just the primary: a video's `poster` and a
|
|
150
|
+
// document's `preview` are assets like any other, and each has identity
|
|
151
|
+
// attrs naming which reference they belong to (ASSET_SLOTS).
|
|
152
|
+
for (const slot of ASSET_SLOTS) {
|
|
153
|
+
const ref = slot.urls
|
|
154
|
+
.map((k) => (typeof node[k] === 'string' ? node[k] : null))
|
|
155
|
+
.find(Boolean)
|
|
156
|
+
const identity = ref ? ids[ref] : null
|
|
157
|
+
if (identity?.id) {
|
|
158
|
+
node[slot.id] = identity.id
|
|
159
|
+
if (identity.ext) node[slot.ext] = identity.ext
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
125
163
|
for (const key of Object.keys(node)) {
|
|
126
164
|
const v = node[key]
|
|
127
165
|
if (typeof v === 'string') { if (map[v]) node[key] = map[v] }
|
|
128
|
-
else rewriteEntityAssets(v, map)
|
|
166
|
+
else rewriteEntityAssets(v, map, ids)
|
|
129
167
|
}
|
|
130
168
|
}
|
|
131
169
|
return node
|
|
@@ -165,6 +203,8 @@ function rewriteEntityAssets(node, map) {
|
|
|
165
203
|
* `publish` fills this for the site's LOCAL extensions after releasing them;
|
|
166
204
|
* the parallel of `info.foundation`, and needed because `extensions` is a
|
|
167
205
|
* sibling of `info` rather than a field in it.
|
|
206
|
+
* @param {Object<string,{id:string,ext:string}>} [opts.assetIds] - local asset ref →
|
|
207
|
+
* backend identity, stamped as `assetId`/`assetExt` BESIDE the URL
|
|
168
208
|
* @param {Object<string,string>} [opts.assetRewrite] - map of local asset ref →
|
|
169
209
|
* backend serve URL; rewrites the entities' media refs before push (the
|
|
170
210
|
* deploy's 2nd emit). Absent → no rewrite (the f225 sync path is unchanged).
|
|
@@ -263,9 +303,11 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
263
303
|
// every non-deploy caller, so the f225 sync path is byte-identical without it.
|
|
264
304
|
const assetRewrite =
|
|
265
305
|
opts.assetRewrite && typeof opts.assetRewrite === 'object' ? opts.assetRewrite : null
|
|
306
|
+
const assetIds =
|
|
307
|
+
opts.assetIds && typeof opts.assetIds === 'object' ? opts.assetIds : null
|
|
266
308
|
if (assetRewrite) {
|
|
267
|
-
if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite)
|
|
268
|
-
for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite)
|
|
309
|
+
if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds)
|
|
310
|
+
for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite, assetIds)
|
|
269
311
|
}
|
|
270
312
|
// Collect the site-root local refs the deploy must upload (`/images/x.png`).
|
|
271
313
|
// 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
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
/**
|