@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.44.4",
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/theming": "^0.1.15",
65
- "@uniweb/content-reader": "^1.2.4"
64
+ "@uniweb/schemas": "^0.2.13",
65
+ "@uniweb/theming": "^0.1.15"
66
66
  },
67
67
  "optionalDependencies": {
68
68
  "@uniweb/runtime": "^0.19.5"
@@ -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)
@@ -28,11 +28,17 @@
28
28
  "name": [
29
29
  "site.yml::name"
30
30
  ],
31
+ "preview": [
32
+ "site.yml::preview"
33
+ ],
31
34
  "tags": [
32
35
  "site.yml::tags"
33
36
  ],
34
37
  "template": [
35
38
  "site.yml::template"
39
+ ],
40
+ "url": [
41
+ "site.yml::$url"
36
42
  ]
37
43
  }
38
44
  },
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
- * Undo the self-scope resolution the producer applies before shipping.
160
- *
161
- * WITHOUT THIS THE ROUND TRIP IS NOT A FIXED POINT, and the failure is silent
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(d, dataSchemas = null) {
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
  }
@@ -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
- const selfScopeOrg =
635
- typeof opts.org === 'string' ? opts.org.replace(/^@/, '').replace(/\/.*$/, '') : ''
636
- const resolveSelfScope = (ref) =>
637
- typeof ref === 'string' && ref.startsWith('@/') && selfScopeOrg
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
+ }
@@ -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
- // `url` and `preview_image` ARE DELIBERATELY ABSENT and must stay absent. Both are
148
- // BACKEND-STAMPED a site's live address and its card image URL are assigned by
149
- // the host so writing either into `site.yml` would launder a deploy-derived value
150
- // into authored config, which is the hazard `submit` / `assistant` / `tracking` are
151
- // annotated against above. Framework emits neither and must project neither.
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
- setIf(data, 'schema', d.schema)
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.preview_image` now.
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
- // `url` and `preview_image` are BACKEND-STAMPED and framework emits NEITHER.
1275
- // A site's live address is assigned at publish and its card image needs a servable
1276
- // URL; a serve location is a per-response answer the host owns read, never
1277
- // constructed. `site-project.js` must also never write them into `site.yml`, or a
1278
- // pull launders a deploy-derived value into authored config.
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.
@@ -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 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
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
- function rewriteEntityAssets(node, map, ids) {
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
- if (siteDoc) rewriteEntityAssets(siteDoc, assetRewrite, assetIds)
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`).
@@ -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
+ }