@uniweb/build 0.44.4 → 0.44.5
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 +4 -4
- package/src/uwx/asset-map.js +74 -2
- package/src/uwx/emit-surface.json +6 -0
- package/src/uwx/index.js +2 -1
- package/src/uwx/records-project.js +21 -26
- package/src/uwx/records.js +6 -7
- package/src/uwx/self-scope.js +78 -0
- package/src/uwx/site-project.js +13 -5
- package/src/uwx/site.js +49 -9
- package/src/uwx/sync-package.js +24 -9
- package/src/uwx/yaml-upsert.js +26 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.5",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
"js-yaml": "^4.1.0",
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
|
+
"@uniweb/content-reader": "^1.2.4",
|
|
60
61
|
"@uniweb/content-writer": "^0.3.4",
|
|
61
62
|
"@uniweb/projections": "^0.6.0",
|
|
62
|
-
"@uniweb/schemas": "^0.2.13",
|
|
63
63
|
"@uniweb/semantic-parser": "^1.4.0",
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/
|
|
64
|
+
"@uniweb/schemas": "^0.2.13",
|
|
65
|
+
"@uniweb/theming": "^0.1.15"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
68
|
"@uniweb/runtime": "^0.19.5"
|
package/src/uwx/asset-map.js
CHANGED
|
@@ -36,11 +36,21 @@
|
|
|
36
36
|
* `config.assets.url` pattern is the whole address, and only the host owns the
|
|
37
37
|
* second half.
|
|
38
38
|
*
|
|
39
|
+
* ⚖️ **What it holds instead is a FINGERPRINT of the served URL** (`served`), and the
|
|
40
|
+
* difference is the point: a hash can recognize an address and cannot compose one.
|
|
41
|
+
* It exists for references that are a BARE STRING — `info.preview`, `info.favicon`,
|
|
42
|
+
* `seo.image`, a section param — where there is no object to stamp `assetId` beside,
|
|
43
|
+
* so the stored value is the serve URL alone. `pull` hashes such a string and, when
|
|
44
|
+
* it matches, puts back the path the author wrote. Should the host ever serve an
|
|
45
|
+
* asset at a new address, the fingerprint simply stops matching and the pull leaves
|
|
46
|
+
* the URL — the honest projection — until the next push records the new one.
|
|
47
|
+
*
|
|
39
48
|
* ⛔ **No mime or size.** The store validates those and they are its to change;
|
|
40
49
|
* a second copy here is a second thing to disagree.
|
|
41
50
|
*/
|
|
42
51
|
|
|
43
52
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
53
|
+
import { createHash } from 'node:crypto'
|
|
44
54
|
import { ASSET_SLOTS } from '@uniweb/semantic-parser'
|
|
45
55
|
import { join } from 'node:path'
|
|
46
56
|
|
|
@@ -70,6 +80,7 @@ export function readAssetMap(siteDir) {
|
|
|
70
80
|
for (const [ref, v] of Object.entries(assets)) {
|
|
71
81
|
if (v && typeof v.id === 'string' && v.id) {
|
|
72
82
|
out[ref] = { id: v.id, ext: typeof v.ext === 'string' ? v.ext : '' }
|
|
83
|
+
if (typeof v.served === 'string' && v.served) out[ref].served = v.served
|
|
73
84
|
}
|
|
74
85
|
}
|
|
75
86
|
return out
|
|
@@ -98,10 +109,15 @@ export function updateAssetMap(siteDir, entries) {
|
|
|
98
109
|
for (const [ref, v] of Object.entries(entries || {})) {
|
|
99
110
|
if (!v?.id) continue
|
|
100
111
|
const was = prior[ref]
|
|
112
|
+
// A new `served` fingerprint is a change too: the host serves these bytes at
|
|
113
|
+
// another address now, and the old fingerprint would stop recognizing it. An
|
|
114
|
+
// entry that carries none — a download learns identity, not an upload's URL —
|
|
115
|
+
// keeps the one already recorded for the same bytes.
|
|
116
|
+
const served = v.served || (was && was.id === v.id ? was.served : undefined)
|
|
101
117
|
if (!was) added.push(ref)
|
|
102
|
-
else if (was.id !== v.id) changed.push(ref)
|
|
118
|
+
else if (was.id !== v.id || (served || '') !== (was.served || '')) changed.push(ref)
|
|
103
119
|
else continue
|
|
104
|
-
prior[ref] = { id: v.id, ext: v.ext || '' }
|
|
120
|
+
prior[ref] = { id: v.id, ext: v.ext || '', ...(served ? { served } : {}) }
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
if (!added.length && !changed.length) return { added, changed, written: false }
|
|
@@ -136,6 +152,26 @@ export function refForAssetId(map, id) {
|
|
|
136
152
|
return null
|
|
137
153
|
}
|
|
138
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The fingerprint `assets.json` keeps of the URL a host serves an asset at.
|
|
157
|
+
*
|
|
158
|
+
* A hash, never the URL: it can recognize an address a pull brings back and it
|
|
159
|
+
* cannot be used to compose one — see the header. Prefixed so a reader of the
|
|
160
|
+
* committed file cannot mistake it for something to fetch.
|
|
161
|
+
*
|
|
162
|
+
* ⛔ Why not carry identity on the wire instead, as content images do? A bare string
|
|
163
|
+
* has no object to put `assetId` beside, and folding it into the value (a URL
|
|
164
|
+
* fragment was tried, 2026-09-10) changes what every consumer receives — a
|
|
165
|
+
* foundation that tells video from image by `src.endsWith('.mp4')` would break on
|
|
166
|
+
* a hosted site. This way the wire value is exactly the host's URL.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} url - the serve URL the upload plan returned, verbatim
|
|
169
|
+
* @returns {string} `sha256:<16 hex>`
|
|
170
|
+
*/
|
|
171
|
+
export function servedFingerprint(url) {
|
|
172
|
+
return `sha256:${createHash('sha256').update(String(url)).digest('hex').slice(0, 16)}`
|
|
173
|
+
}
|
|
174
|
+
|
|
139
175
|
/**
|
|
140
176
|
* Restore authored asset paths on a document being projected back to files.
|
|
141
177
|
*
|
|
@@ -188,5 +224,41 @@ export function restoreAssetRefs(document, map) {
|
|
|
188
224
|
for (const v of Object.values(node)) visit(v)
|
|
189
225
|
}
|
|
190
226
|
visit(document)
|
|
227
|
+
|
|
228
|
+
// ⭐ BARE STRINGS — a reference with no object to carry identity beside it
|
|
229
|
+
// (`info.preview`, `info.favicon`, `seo.image`, a section param). The stored value
|
|
230
|
+
// is the serve URL alone, so it is recognized by the fingerprint the push recorded
|
|
231
|
+
// for it (`servedFingerprint`). A string the map has no fingerprint for stays as
|
|
232
|
+
// the URL that works — the same rule as an unknown id above.
|
|
233
|
+
const byServed = new Map()
|
|
234
|
+
for (const [ref, v] of Object.entries(map || {})) {
|
|
235
|
+
if (v?.served && !byServed.has(v.served)) byServed.set(v.served, ref)
|
|
236
|
+
}
|
|
237
|
+
if (byServed.size) {
|
|
238
|
+
const restore = (v) => {
|
|
239
|
+
if (typeof v !== 'string' || !looksLikeAddress(v)) return v
|
|
240
|
+
const ref = byServed.get(servedFingerprint(v))
|
|
241
|
+
if (!ref) return v
|
|
242
|
+
stats.restored++
|
|
243
|
+
return ref
|
|
244
|
+
}
|
|
245
|
+
const walk = (node) => {
|
|
246
|
+
if (Array.isArray(node)) {
|
|
247
|
+
for (let i = 0; i < node.length; i++) {
|
|
248
|
+
if (typeof node[i] === 'string') node[i] = restore(node[i])
|
|
249
|
+
else walk(node[i])
|
|
250
|
+
}
|
|
251
|
+
} else if (node && typeof node === 'object') {
|
|
252
|
+
for (const key of Object.keys(node)) {
|
|
253
|
+
if (typeof node[key] === 'string') node[key] = restore(node[key])
|
|
254
|
+
else walk(node[key])
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
walk(document)
|
|
259
|
+
}
|
|
191
260
|
return stats
|
|
192
261
|
}
|
|
262
|
+
|
|
263
|
+
// Only a string that could be a served address is worth hashing.
|
|
264
|
+
const looksLikeAddress = (v) => v.startsWith('/') || /^https?:\/\//i.test(v)
|
package/src/uwx/index.js
CHANGED
|
@@ -54,7 +54,7 @@ export {
|
|
|
54
54
|
queriesYmlPath,
|
|
55
55
|
QUERIES_YML_RELPATH,
|
|
56
56
|
} from './queries-config.js'
|
|
57
|
-
export { upsertYamlScalar } from './yaml-upsert.js'
|
|
57
|
+
export { upsertYamlScalar, removeYamlScalar } from './yaml-upsert.js'
|
|
58
58
|
export { buildFolderEntity,
|
|
59
59
|
collectFolderItemUuids,
|
|
60
60
|
stampFolderItemUuids
|
|
@@ -83,6 +83,7 @@ export {
|
|
|
83
83
|
refForAssetId,
|
|
84
84
|
restoreAssetRefs,
|
|
85
85
|
ASSET_MAP_FILE,
|
|
86
|
+
servedFingerprint,
|
|
86
87
|
} from './asset-map.js'
|
|
87
88
|
export {
|
|
88
89
|
diffSiteUnits,
|
|
@@ -40,6 +40,7 @@ import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './proje
|
|
|
40
40
|
import { defaultSchema, deferredFromSchema, foundationDataSchemas } from './queries-config.js'
|
|
41
41
|
import { poolDirsForSchema, ENTITIES_DIR } from '../site/entity-pool.js'
|
|
42
42
|
import { isContentBodyField } from './data-schema.js'
|
|
43
|
+
import { unresolveSelfScope } from './self-scope.js'
|
|
43
44
|
import { unwrapLocalized } from './backfill.js'
|
|
44
45
|
import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
|
|
45
46
|
import { buildFreeformRecordPath } from '../i18n/freeform.js'
|
|
@@ -155,29 +156,10 @@ function recordDirFor(siteRoot, model, selfOrg) {
|
|
|
155
156
|
return dirs ? join(siteRoot, ENTITIES_DIR, ...dirs) : null
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
* on both ends. `@/article` is a FOUNDATION-RELATIVE alias: the producer resolves
|
|
163
|
-
* it to `@acme/article` before it ships, because the backend resolves Models by
|
|
164
|
-
* name and never mints. So a record authored under `entities/article/` comes back
|
|
165
|
-
* as `@acme/article` and, placed literally, lands under `entities/acme/article/` —
|
|
166
|
-
* a different schema folder, which the next build reads as a different schema.
|
|
167
|
-
*
|
|
168
|
-
* ⚠️ It did not show before records were placed by their model: every record went
|
|
169
|
-
* to `collections/<collection>/` regardless, so the resolution had nowhere to leak.
|
|
170
|
-
*
|
|
171
|
-
* ⭐ The site records its own org at create (`site.yml::$org` — "whose this is"),
|
|
172
|
-
* which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
|
|
173
|
-
* genuinely is that org's, and `@/` would be a lie.
|
|
174
|
-
*/
|
|
175
|
-
export function unresolveSelfScope(model, selfOrg) {
|
|
176
|
-
if (typeof model !== 'string' || !selfOrg) return model
|
|
177
|
-
const org = String(selfOrg).replace(/^@/, '').replace(/\/.*$/, '')
|
|
178
|
-
if (!org) return model
|
|
179
|
-
return model.startsWith(`@${org}/`) ? `@/${model.slice(org.length + 2)}` : model
|
|
180
|
-
}
|
|
159
|
+
// ⚠️ Undoing the producer's self-scope resolution (`unresolveSelfScope`) lives in
|
|
160
|
+
// `./self-scope.js`, beside the forward rule it inverts. It did not show before
|
|
161
|
+
// records were placed by their model: every record went to
|
|
162
|
+
// `collections/<collection>/` regardless, so the resolution had nowhere to leak.
|
|
181
163
|
|
|
182
164
|
// Resolve a record's (collection, slug): the folder index first (authoritative on
|
|
183
165
|
// a read), the record document's `$id` (`<collection>/<slug>`) as a fallback.
|
|
@@ -252,7 +234,16 @@ function isDerivedDeferred(d, dataSchemas) {
|
|
|
252
234
|
return d.deferred.every((f) => a.has(f))
|
|
253
235
|
}
|
|
254
236
|
|
|
255
|
-
function declToFileShape(
|
|
237
|
+
function declToFileShape(wire, dataSchemas = null, selfOrg = null) {
|
|
238
|
+
// ⛔ UNDO THE PRODUCER'S QUALIFICATION FIRST, before anything compares against
|
|
239
|
+
// `schema`. The push qualifies a foundation-relative `@/x` to `@org/x`
|
|
240
|
+
// (`site.js::queriesNested`), and both checks below are keyed by the author's
|
|
241
|
+
// `@/x`: against `@org/x` the query-name default would never match — writing an
|
|
242
|
+
// explicit schema the author never had — and the derived-`deferred` lookup would
|
|
243
|
+
// miss, persisting a derivation into their file (the 2026-08-29 defect).
|
|
244
|
+
const d = selfOrg && typeof wire.schema === 'string'
|
|
245
|
+
? { ...wire, schema: unresolveSelfScope(wire.schema, selfOrg) }
|
|
246
|
+
: wire
|
|
256
247
|
const name = d.name || d.$id
|
|
257
248
|
const decl = {}
|
|
258
249
|
|
|
@@ -328,9 +319,12 @@ function declToFileShape(d, dataSchemas = null) {
|
|
|
328
319
|
* @param {object} params
|
|
329
320
|
* @param {object} params.document - a site-content `$`-document (`{ queries }`)
|
|
330
321
|
* @param {string} params.siteRoot
|
|
322
|
+
* @param {string} [params.org] - the site's own org, so a `schema` the producer
|
|
323
|
+
* qualified from `@/x` is written back as `@/x`. Defaults to `site.yml::$org`,
|
|
324
|
+
* the same default `recordsToProject` places records by.
|
|
331
325
|
* @returns {{ collections?: 'updated'|'unchanged' }}
|
|
332
326
|
*/
|
|
333
|
-
export function declarationsToQueriesYml({ document, siteRoot }) {
|
|
327
|
+
export function declarationsToQueriesYml({ document, siteRoot, org }) {
|
|
334
328
|
const decls = Array.isArray(document?.queries) ? document.queries : []
|
|
335
329
|
const report = {}
|
|
336
330
|
if (decls.length === 0) return report
|
|
@@ -348,10 +342,11 @@ export function declarationsToQueriesYml({ document, siteRoot }) {
|
|
|
348
342
|
siteYml = null
|
|
349
343
|
}
|
|
350
344
|
const dataSchemas = siteYml ? foundationDataSchemas(siteRoot, siteYml) : null
|
|
345
|
+
const selfOrg = org ?? readSiteOrg(siteRoot)
|
|
351
346
|
|
|
352
347
|
const queries = {}
|
|
353
348
|
for (const d of decls) {
|
|
354
|
-
const { name, decl } = declToFileShape(d, dataSchemas)
|
|
349
|
+
const { name, decl } = declToFileShape(d, dataSchemas, selfOrg)
|
|
355
350
|
if (!name) continue
|
|
356
351
|
queries[name] = decl
|
|
357
352
|
}
|
package/src/uwx/records.js
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
} from '../site/entity-pool.js'
|
|
56
56
|
import { toDataSchemaDeclaration, isProseMirrorField, isMarkupTextField, isContentBodyField } from './data-schema.js'
|
|
57
57
|
import { emitEntitySyncPackage } from './entity-document.js'
|
|
58
|
+
import { resolveSelfScope } from './self-scope.js'
|
|
58
59
|
import { sha256Hex, toJsonBuffer } from './manifest.js'
|
|
59
60
|
import { markdownToProseMirror } from '@uniweb/content-reader'
|
|
60
61
|
import { LOCALIZED_FIELD_ASSUMPTION, localize } from './localize.js'
|
|
@@ -631,12 +632,10 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
631
632
|
// `resolveDeclaration` already matches a fully-qualified name against the
|
|
632
633
|
// foundation's `@/`-keyed `dataSchemas`, so a resolved name looks up correctly and
|
|
633
634
|
// `declaration.name` — the value that becomes `$model` — is the resolved one.
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
? `@${selfScopeOrg}/${ref.slice(2)}`
|
|
639
|
-
: ref
|
|
635
|
+
//
|
|
636
|
+
// ⛔ The rule lives in `./self-scope.js`, shared with the `queries` Section
|
|
637
|
+
// (`site.js::queriesNested`): a query's `schema` must name exactly the Model
|
|
638
|
+
// these records are stored under, so both go through one function with one org.
|
|
640
639
|
// Collections that resolved no data schema (the convention-default soft-skip
|
|
641
640
|
// below) — not synced as folder entities. The composite deploy delivers these
|
|
642
641
|
// statically (the "data ball") instead, so the caller can route them there.
|
|
@@ -647,7 +646,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
|
|
|
647
646
|
const seen = new Set()
|
|
648
647
|
for (const { name, decl } of mapped) {
|
|
649
648
|
const declaredModel = decl.schema || decl.model
|
|
650
|
-
const modelName = resolveSelfScope(declaredModel)
|
|
649
|
+
const modelName = resolveSelfScope(declaredModel, opts.org)
|
|
651
650
|
// Unresolvable `@/` — no org is known. Ship it rather than throwing (a `status`
|
|
652
651
|
// probe on a never-pushed site has no org and must still count), but say so:
|
|
653
652
|
// the backend's refusal names a missing Model and cannot name this cause.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// `@/x` — a Model ref in the site's own foundation scope — and its qualified form.
|
|
2
|
+
//
|
|
3
|
+
// `@/member` names a data schema in the foundation's own `schemas/`. It is
|
|
4
|
+
// AUTHORING shorthand: a backend resolves Models by name and never mints one, so a
|
|
5
|
+
// ref that leaves the CLI must be the name the Model was stored under,
|
|
6
|
+
// `@<org>/member`. Everything that reads a ref off the wire matches it exactly —
|
|
7
|
+
// the entity store at restore, and a records service answering a hosted page's
|
|
8
|
+
// query.
|
|
9
|
+
//
|
|
10
|
+
// ⭐ ONE RULE FOR EVERY PATH THAT SHIPS A REF FROM ONE PUBLISH, and its inverse:
|
|
11
|
+
//
|
|
12
|
+
// - a record's `$model` `records.js::buildRecordEntities`
|
|
13
|
+
// - a query's `schema` `site.js::queriesNested` (the `queries` Section)
|
|
14
|
+
// - the author's spelling, on pull `records-project.js` (placement and declarations)
|
|
15
|
+
//
|
|
16
|
+
// ⛔ They must agree on one alias. When the query path shipped `@/member` verbatim
|
|
17
|
+
// while the records beside it were qualified, the query named a Model its own
|
|
18
|
+
// records were not stored under: a hosted page's question named a Model that does
|
|
19
|
+
// not exist, the records service refused that key, and the section rendered
|
|
20
|
+
// nothing — the key absent from `content.data`, the reason only on
|
|
21
|
+
// `block.dataError`, the console clean. Nothing on either side was malformed; the
|
|
22
|
+
// two paths disagreed.
|
|
23
|
+
//
|
|
24
|
+
// Registering a foundation qualifies its declarations separately, from the
|
|
25
|
+
// foundation's publish scope (`registry-package.js`). That is a different input
|
|
26
|
+
// (the foundation's scope, not the site's publish org) and stays there.
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An org handle as `--org` or `site.yml::$org` gives it — `@acme`, `acme`, or
|
|
30
|
+
* `@acme/…` — reduced to the bare handle, or `''`.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} org
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function bareOrg(org) {
|
|
36
|
+
return typeof org === 'string' ? org.replace(/^@/, '').replace(/\/.*$/, '') : ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* `@/x` → `@<org>/x`. Any other ref (`@std/x`, `@acme/x`) passes through, and so
|
|
41
|
+
* does `@/x` when no org is known — callers that ship an unresolved alias say so
|
|
42
|
+
* themselves (`buildRecordEntities` warns per query).
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} ref
|
|
45
|
+
* @param {unknown} org
|
|
46
|
+
* @returns {unknown}
|
|
47
|
+
*/
|
|
48
|
+
export function resolveSelfScope(ref, org) {
|
|
49
|
+
const handle = bareOrg(org)
|
|
50
|
+
return typeof ref === 'string' && ref.startsWith('@/') && handle
|
|
51
|
+
? `@${handle}/${ref.slice(2)}`
|
|
52
|
+
: ref
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The inverse, for a pull: `@<org>/x` → `@/x` for the site's own org.
|
|
57
|
+
*
|
|
58
|
+
* ⛔ WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
|
|
59
|
+
* on both ends. A record authored under `entities/article/` comes back as
|
|
60
|
+
* `@acme/article` and, placed literally, lands under `entities/acme/article/` — a
|
|
61
|
+
* different schema folder, which the next build reads as a different schema. A
|
|
62
|
+
* query declared `schema: '@/member'` comes back as `@acme/member`, and one that
|
|
63
|
+
* relied on the query-name default comes back with an explicit schema it never
|
|
64
|
+
* had.
|
|
65
|
+
*
|
|
66
|
+
* ⭐ The site records its own org at create (`site.yml::$org` — "whose this is"),
|
|
67
|
+
* which is exactly the inverse. A model scoped to ANOTHER org is left alone: it
|
|
68
|
+
* genuinely is that org's, and `@/` would be a lie.
|
|
69
|
+
*
|
|
70
|
+
* @param {unknown} ref
|
|
71
|
+
* @param {unknown} org
|
|
72
|
+
* @returns {unknown}
|
|
73
|
+
*/
|
|
74
|
+
export function unresolveSelfScope(ref, org) {
|
|
75
|
+
const handle = bareOrg(org)
|
|
76
|
+
if (typeof ref !== 'string' || !handle) return ref
|
|
77
|
+
return ref.startsWith(`@${handle}/`) ? `@/${ref.slice(handle.length + 2)}` : ref
|
|
78
|
+
}
|
package/src/uwx/site-project.js
CHANGED
|
@@ -144,11 +144,19 @@ const INFO_TO_SITE_YML = {
|
|
|
144
144
|
// ⭐ `tags` — authored, non-localized tokens; the filter facet for a list of site
|
|
145
145
|
// cards. Round-trips verbatim like any authored list.
|
|
146
146
|
tags: 'tags',
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
147
|
+
// ⭐ `preview` — the card image, verbatim: the app's timestamp, an author's URL, or
|
|
148
|
+
// the path an author wrote for an image in the project, which `restoreAssetRefs`
|
|
149
|
+
// has already put back by recognizing the serve URL the push recorded in
|
|
150
|
+
// `assets.json` (asset-map.js). [Diego, 2026-09-10]
|
|
151
|
+
preview: 'preview',
|
|
152
|
+
// ⭐ `url` — where the site is live → `site.yml::$url`. The backend records it at
|
|
153
|
+
// every publish, and pull is its only way onto disk; recorded rather than authored,
|
|
154
|
+
// so it lands under the `$` like `$uuid`.
|
|
155
|
+
//
|
|
156
|
+
// ⚠️ Both were listed here as "DELIBERATELY ABSENT… BACKEND-STAMPED" until
|
|
157
|
+
// 2026-09-10. Leaving an app-written field out of this map is what lets the next
|
|
158
|
+
// push destroy it, since `info` is replaced whole.
|
|
159
|
+
url: '$url',
|
|
152
160
|
}
|
|
153
161
|
|
|
154
162
|
// ── `settings` Section → site.yml ─────────────────────────────────────────────
|
package/src/uwx/site.js
CHANGED
|
@@ -62,6 +62,7 @@ import { unwrapLocalized } from './backfill.js'
|
|
|
62
62
|
import { loadFreeformTranslation } from '../i18n/freeform.js'
|
|
63
63
|
import { upsertYamlScalar } from './yaml-upsert.js'
|
|
64
64
|
import { resolveQueriesConfig } from './queries-config.js'
|
|
65
|
+
import { resolveSelfScope } from './self-scope.js'
|
|
65
66
|
|
|
66
67
|
const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
|
|
67
68
|
|
|
@@ -69,6 +70,16 @@ function setIf(obj, key, value) {
|
|
|
69
70
|
if (value !== undefined) obj[key] = value
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
// A YAML scalar the author may have written unquoted: `20260910` loads as a number
|
|
74
|
+
// and `2026-09-10` as a Date. Carry either as the string it stands for; anything
|
|
75
|
+
// else that is not a string is not a value a string field can hold.
|
|
76
|
+
function scalarString(value) {
|
|
77
|
+
if (typeof value === 'string') return value
|
|
78
|
+
if (typeof value === 'number') return String(value)
|
|
79
|
+
if (value instanceof Date) return value.toISOString()
|
|
80
|
+
return undefined
|
|
81
|
+
}
|
|
82
|
+
|
|
72
83
|
// Credential-shaped keys, mirroring the set the delivery edge strips on the
|
|
73
84
|
// reading side. Deliberately the SAME list rather than a stricter one, so the
|
|
74
85
|
// two guards are visibly twins and a key added to one is obviously owed to the
|
|
@@ -731,6 +742,9 @@ export function isSiteRelativeExtensionUrl(decl) {
|
|
|
731
742
|
* @param {object} declarations resolved collection declarations, keyed by name
|
|
732
743
|
* @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
|
|
733
744
|
* response or a pull. Absent on a first sync, where minting is correct.
|
|
745
|
+
* @param {string} [org] the publish org. A foundation-relative `schema` (`@/x`)
|
|
746
|
+
* is qualified with it (`./self-scope.js`), exactly as the records' `$model`
|
|
747
|
+
* is — see the note at the `schema` line below.
|
|
734
748
|
*/
|
|
735
749
|
// ⛔ KEYS THAT MUST NOT REACH THE WIRE. Everything else on an authored declaration
|
|
736
750
|
// is emitted, including fields this build does not model — see the note in
|
|
@@ -786,13 +800,19 @@ const DECL_NOT_ON_WIRE = new Set([
|
|
|
786
800
|
'filter'
|
|
787
801
|
])
|
|
788
802
|
|
|
789
|
-
function queriesNested(declarations, uuids = null) {
|
|
803
|
+
function queriesNested(declarations, uuids = null, org = null) {
|
|
790
804
|
const out = []
|
|
791
805
|
for (const [name, d] of Object.entries(declarations)) {
|
|
792
806
|
const data = {}
|
|
793
807
|
const source = d.path ? { path: d.path } : d.url ? { url: d.url } : d.source
|
|
794
808
|
setIf(data, 'source', source)
|
|
795
|
-
|
|
809
|
+
// ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME ORG AS THE RECORDS' `$model`
|
|
810
|
+
// (`records.js::buildRecordEntities`). A consumer answers a query by matching
|
|
811
|
+
// this name against the Models its records were stored under, so a verbatim
|
|
812
|
+
// `@/member` beside records stored as `@org/member` names nothing: the query
|
|
813
|
+
// resolves no Model and the page that binds it renders empty. The pull puts the
|
|
814
|
+
// author's `@/` back (`records-project.js::declarationsToQueriesYml`).
|
|
815
|
+
setIf(data, 'schema', resolveSelfScope(d.schema, org))
|
|
796
816
|
setIf(data, 'sort', d.sort)
|
|
797
817
|
// Legacy `filter:` is not synced — it is translated to `where` upstream
|
|
798
818
|
// (the canonical predicate). No legacy fields on the wire.
|
|
@@ -1021,7 +1041,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1021
1041
|
// `ogTitle`, `ogDescription`, `noindex`, `canonical`, `changefreq`, `priority`
|
|
1022
1042
|
// (`core/src/seo.js`). Two of those are literally sitemap.xml columns. It only
|
|
1023
1043
|
// ever passed the card test because `image` was inside it; the card's picture is
|
|
1024
|
-
// `info.
|
|
1044
|
+
// `info.preview` now.
|
|
1025
1045
|
setIf(settings, 'seo', siteYml.seo)
|
|
1026
1046
|
// ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
|
|
1027
1047
|
// (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
|
|
@@ -1076,6 +1096,9 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
|
|
|
1076
1096
|
* @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
|
|
1077
1097
|
* the site's effective default locale (`defaultLanguage || languages[0] ||
|
|
1078
1098
|
* 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
|
|
1099
|
+
* @param {string} [opts.org] - the publish org, which qualifies a query's
|
|
1100
|
+
* foundation-relative `schema` (`@/x` → `@org/x`). Pass the same org the
|
|
1101
|
+
* records are emitted with; absent, `@/x` ships as written.
|
|
1079
1102
|
* @returns {Promise<object>} the section-keyed `$`-document:
|
|
1080
1103
|
* `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, queries }`
|
|
1081
1104
|
*/
|
|
@@ -1271,11 +1294,28 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1271
1294
|
// over an already-fetched list and never asks the database, while a DB-filterable
|
|
1272
1295
|
// facet is separately useful — only the second needs a predicable brief field.
|
|
1273
1296
|
setIf(info, 'tags', siteYml.tags)
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1297
|
+
// ⭐ `preview` — the site card's image. ONE field, TWO writers, and it round-trips
|
|
1298
|
+
// whichever wrote it [Diego, 2026-09-10]:
|
|
1299
|
+
// · the APP, when it generates a card image — a timestamp, carried verbatim;
|
|
1300
|
+
// · an AUTHOR, deliberately — typically a template site's custom image: a URL, or
|
|
1301
|
+
// a site-root path to an image in the project, which push uploads like any
|
|
1302
|
+
// content image and sends as its serve URL, and which pull puts back as the
|
|
1303
|
+
// path the author wrote (`restoreAssetRefs` recognizes the URL by the
|
|
1304
|
+
// fingerprint `assets.json` recorded for it).
|
|
1305
|
+
// The app leaves an author's value alone.
|
|
1306
|
+
//
|
|
1307
|
+
// ⚠️ This read "`url` and `preview_image` are BACKEND-STAMPED and framework emits
|
|
1308
|
+
// NEITHER" until 2026-09-10. Neither half held: nothing stamped `url`, and leaving
|
|
1309
|
+
// an app-written field off an allowlist destroys it on every push, because `info`
|
|
1310
|
+
// is replaced whole.
|
|
1311
|
+
setIf(info, 'preview', scalarString(siteYml.preview))
|
|
1312
|
+
// ⭐ `url` — where the site is live, so a site card can link to it without opening
|
|
1313
|
+
// the editor. The backend records it at every publish (stated by backend,
|
|
1314
|
+
// 2026-09-10) and pull brings it into `site.yml::$url`; nothing in framework writes
|
|
1315
|
+
// it. The `$` marks it as recorded rather than authored, like `$uuid` — and keeps it
|
|
1316
|
+
// out of the rendered payload, where a bare `url:` would sit beside `seo.baseUrl`,
|
|
1317
|
+
// the authored canonical address.
|
|
1318
|
+
setIf(info, 'url', siteYml.$url)
|
|
1279
1319
|
|
|
1280
1320
|
const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
|
|
1281
1321
|
const pagesPath = siteYml.paths?.pages
|
|
@@ -1327,7 +1367,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
|
|
|
1327
1367
|
//
|
|
1328
1368
|
// ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
|
|
1329
1369
|
// consumer sees, leave the identifier alone.
|
|
1330
|
-
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids)
|
|
1370
|
+
doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, opts.org)
|
|
1331
1371
|
// Emitted ONLY when the file declares the key — see the header above
|
|
1332
1372
|
// `serviceRecords`: on a replaced Section, absent and empty are different
|
|
1333
1373
|
// requests and one of them is destructive.
|
package/src/uwx/sync-package.js
CHANGED
|
@@ -126,10 +126,13 @@ function walkEntityAssets(node, visitor) {
|
|
|
126
126
|
//
|
|
127
127
|
// ⚠️ **This bullet used to justify itself with "no deployment emits
|
|
128
128
|
// `config.assets.url` yet". FALSIFIED 2026-08-18** by the backend lane:
|
|
129
|
-
// `serve` publishes the pattern **unconditionally**, falling back to
|
|
130
|
-
// direct form, so a deployment with no asset storage emits
|
|
131
|
-
//
|
|
132
|
-
//
|
|
129
|
+
// `serve` publishes the pattern **unconditionally**, falling back to a
|
|
130
|
+
// direct form, so a deployment with no asset storage emits a pattern rather
|
|
131
|
+
// than nothing (measured by them on a running daemon, not read off a type).
|
|
132
|
+
// ⛔ This named that direct form `/gateway/asset/dist/{id}/base.{ext}` until
|
|
133
|
+
// 2026-09-10, and there is no such route [Diego] — the name had spread into
|
|
134
|
+
// tests and kb as if it were THE serve URL. A serve URL is whatever the host
|
|
135
|
+
// returns; nothing here depends on its shape. ⛔ Scoped to deployments
|
|
133
136
|
// running code from 2026-08-17 or later; an older one emits nothing, and
|
|
134
137
|
// absent stays absent. A present-tense negative about someone else's
|
|
135
138
|
// deployments is the claim nothing in this repo can ever contradict — it
|
|
@@ -157,19 +160,22 @@ function walkEntityAssets(node, visitor) {
|
|
|
157
160
|
// ProseMirror image node's attrs (`{src, alt, …}`) and a section background's
|
|
158
161
|
// media object (`{image: {src}}`) with one rule — the two shapes framework
|
|
159
162
|
// resolves, reached through the same walk.
|
|
160
|
-
|
|
163
|
+
//
|
|
164
|
+
// `noStamp` is the one object that must get NO identity attrs even when a slot
|
|
165
|
+
// matches — the site's `info`, whose fields the host declares (see the call site).
|
|
166
|
+
function rewriteEntityAssets(node, map, ids, noStamp = null) {
|
|
161
167
|
if (Array.isArray(node)) {
|
|
162
168
|
for (let i = 0; i < node.length; i++) {
|
|
163
169
|
const v = node[i]
|
|
164
170
|
if (typeof v === 'string') { if (map[v]) node[i] = map[v] }
|
|
165
|
-
else rewriteEntityAssets(v, map, ids)
|
|
171
|
+
else rewriteEntityAssets(v, map, ids, noStamp)
|
|
166
172
|
}
|
|
167
173
|
return node
|
|
168
174
|
}
|
|
169
175
|
if (node && typeof node === 'object') {
|
|
170
176
|
// Stamp BEFORE the string swap below, while the reference is still the
|
|
171
177
|
// local ref the ids map is keyed by.
|
|
172
|
-
if (ids) {
|
|
178
|
+
if (ids && node !== noStamp) {
|
|
173
179
|
// Every asset slot, not just the primary: a video's `poster` and a
|
|
174
180
|
// document's `preview` are assets like any other, and each has identity
|
|
175
181
|
// attrs naming which reference they belong to (ASSET_SLOTS).
|
|
@@ -187,7 +193,7 @@ function rewriteEntityAssets(node, map, ids) {
|
|
|
187
193
|
for (const key of Object.keys(node)) {
|
|
188
194
|
const v = node[key]
|
|
189
195
|
if (typeof v === 'string') { if (map[v]) node[key] = map[v] }
|
|
190
|
-
else rewriteEntityAssets(v, map, ids)
|
|
196
|
+
else rewriteEntityAssets(v, map, ids, noStamp)
|
|
191
197
|
}
|
|
192
198
|
}
|
|
193
199
|
return node
|
|
@@ -311,6 +317,10 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
311
317
|
? await siteProjectToDocument(siteRoot, {
|
|
312
318
|
sourceLocale,
|
|
313
319
|
...(opts.queryUuids ? { queryUuids: opts.queryUuids } : {}),
|
|
320
|
+
// ⛔ THE SAME ORG `buildRecordEntities` WAS GIVEN ABOVE. A query's `schema`
|
|
321
|
+
// must name the Model its records are stored under, and both are qualified
|
|
322
|
+
// from one `@/x` by one rule (`./self-scope.js`) — so they take one org.
|
|
323
|
+
...(opts.org ? { org: opts.org } : {}),
|
|
314
324
|
// Withhold the `$services`/`$secrets` Sections when the caller has
|
|
315
325
|
// determined the file is not asking for anything new by them. Passed
|
|
316
326
|
// through rather than decided here: the last-agreed state is project
|
|
@@ -387,7 +397,12 @@ export async function emitSyncPackages(siteRoot, opts = {}) {
|
|
|
387
397
|
const assetIds =
|
|
388
398
|
opts.assetIds && typeof opts.assetIds === 'object' ? opts.assetIds : null
|
|
389
399
|
if (assetRewrite) {
|
|
390
|
-
|
|
400
|
+
// ⛔ No identity attrs on `info`: it is a Section whose fields the host declares,
|
|
401
|
+
// and `preview` is a real ASSET_SLOTS slot, so stamping would add
|
|
402
|
+
// `previewAssetId`/`previewAssetExt` — fields the host refuses. Its bare strings
|
|
403
|
+
// are still swapped for their serve URLs, which `assets.json` recognizes on pull
|
|
404
|
+
// by fingerprint (asset-map.js → `servedFingerprint`).
|
|
405
|
+
if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds, siteDoc.info)
|
|
391
406
|
for (const e of col.entities) rewriteEntityAssets(e.document, assetRewrite, assetIds)
|
|
392
407
|
}
|
|
393
408
|
// Collect the site-root local refs the deploy must upload (`/images/x.png`).
|
package/src/uwx/yaml-upsert.js
CHANGED
|
@@ -51,3 +51,29 @@ export function upsertYamlScalar(filePath, key, value) {
|
|
|
51
51
|
writeFileSync(filePath, next)
|
|
52
52
|
return true
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove a TOP-LEVEL scalar key from the YAML file at `filePath`, preserving every
|
|
57
|
+
* other line (comments included) — the inverse of `upsertYamlScalar`, with the same
|
|
58
|
+
* scope: one `key: value` line at column 0. A key followed by indented lines, or
|
|
59
|
+
* opening a block scalar (`|` / `>`), is not a one-line scalar and is left alone
|
|
60
|
+
* rather than half-removed.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} filePath
|
|
63
|
+
* @param {string} key - a top-level scalar key (e.g. `$url`)
|
|
64
|
+
* @returns {boolean} true if the file changed
|
|
65
|
+
*/
|
|
66
|
+
export function removeYamlScalar(filePath, key) {
|
|
67
|
+
if (!existsSync(filePath)) return false
|
|
68
|
+
const lines = readFileSync(filePath, 'utf8').split('\n')
|
|
69
|
+
const esc = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
70
|
+
const re = new RegExp(`^${esc}:(.*)$`)
|
|
71
|
+
const at = lines.findIndex((l) => re.test(l))
|
|
72
|
+
if (at === -1) return false
|
|
73
|
+
const inline = lines[at].replace(re, '$1').trim()
|
|
74
|
+
const continues = at + 1 < lines.length && /^[ \t]+\S/.test(lines[at + 1])
|
|
75
|
+
if (continues || /^[|>]/.test(inline)) return false
|
|
76
|
+
lines.splice(at, 1)
|
|
77
|
+
writeFileSync(filePath, lines.join('\n'))
|
|
78
|
+
return true
|
|
79
|
+
}
|