@uniweb/build 0.44.4 → 0.45.0

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.
@@ -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
@@ -53,7 +53,9 @@ import {
53
53
  applyWildcardOrder,
54
54
  processMarkdownFile,
55
55
  fetchFromDataShorthand,
56
+ assertRouteFolder,
56
57
  } from '../site/content-collector.js'
58
+ import { refuseUnder } from '../site/data-fetcher.js'
57
59
  import { normalizeHideIn } from '../site/nav-visibility.js'
58
60
  import { resolveDefaultLocale, validateLanguageConfig, queryDataUrl } from '@uniweb/core'
59
61
  import { emitEntitySyncPackage } from './entity-document.js'
@@ -62,6 +64,7 @@ import { unwrapLocalized } from './backfill.js'
62
64
  import { loadFreeformTranslation } from '../i18n/freeform.js'
63
65
  import { upsertYamlScalar } from './yaml-upsert.js'
64
66
  import { resolveQueriesConfig } from './queries-config.js'
67
+ import { resolveSelfScope } from './self-scope.js'
65
68
 
66
69
  const SITE_ENTITY_KEY = 'site-content' // one content entity per site project
67
70
 
@@ -69,6 +72,16 @@ function setIf(obj, key, value) {
69
72
  if (value !== undefined) obj[key] = value
70
73
  }
71
74
 
75
+ // A YAML scalar the author may have written unquoted: `20260910` loads as a number
76
+ // and `2026-09-10` as a Date. Carry either as the string it stands for; anything
77
+ // else that is not a string is not a value a string field can hold.
78
+ function scalarString(value) {
79
+ if (typeof value === 'string') return value
80
+ if (typeof value === 'number') return String(value)
81
+ if (value instanceof Date) return value.toISOString()
82
+ return undefined
83
+ }
84
+
72
85
  // Credential-shaped keys, mirroring the set the delivery edge strips on the
73
86
  // reading side. Deliberately the SAME list rather than a stricter one, so the
74
87
  // two guards are visibly twins and a key added to one is obviously owed to the
@@ -225,6 +238,10 @@ function buildPageData(config, ctx) {
225
238
  // 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
226
239
  // carried one dataset for a page that asked for several.
227
240
  let fetch = config.fetch ?? fetchFromDataShorthand(config.data)
241
+ // `where: { path: { under } }` is refused here as the build refuses it
242
+ // (`parseFetchConfig`): a site that cannot build must not sync either. A
243
+ // section's fetch is refused where the collector parses it.
244
+ for (const one of [fetch].flat()) refuseUnder(one?.where, 'fetch')
228
245
  // Resolve the authored `query:` shorthand to the runtime-fetchable
229
246
  // `path: /data/<name>.json` (the static convention the default-fetcher uses).
230
247
  // A shell/backend-hosted site renders client-side with NO prerender, so the
@@ -537,8 +554,13 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
537
554
  const { siteRoot, siteIndex, sourceLocale, translations } = ctx
538
555
  const folders = await orderedSubfolders(dirPath, inheritedMode, parentConfig)
539
556
  const out = []
557
+ // A folder inside a `[...path]` folder can never be reached, and `[dir]` /
558
+ // `[path]` would name a route variable — refused here as the collector refuses
559
+ // them, so a site that cannot build cannot sync either (ruled 2026-09-11).
560
+ const insideCatchAll = (parentSlugPath || '').split('/').includes(CATCH_ALL_MARKER)
540
561
  for (let i = 0; i < folders.length; i++) {
541
562
  const f = folders[i]
563
+ assertRouteFolder(f.dirName, insideCatchAll ? '/:path*' : '/')
542
564
  const dyn = f.dirName.match(DYNAMIC_RE)
543
565
  const slug = dyn ? dyn[1] : f.name
544
566
  const mode = f.source === 'folder.yml' ? 'folder' : 'page'
@@ -731,6 +753,9 @@ export function isSiteRelativeExtensionUrl(decl) {
731
753
  * @param {object} declarations resolved collection declarations, keyed by name
732
754
  * @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push
733
755
  * response or a pull. Absent on a first sync, where minting is correct.
756
+ * @param {string} [org] the publish org. A foundation-relative `schema` (`@/x`)
757
+ * is qualified with it (`./self-scope.js`), exactly as the records' `$model`
758
+ * is — see the note at the `schema` line below.
734
759
  */
735
760
  // ⛔ KEYS THAT MUST NOT REACH THE WIRE. Everything else on an authored declaration
736
761
  // is emitted, including fields this build does not model — see the note in
@@ -786,13 +811,20 @@ const DECL_NOT_ON_WIRE = new Set([
786
811
  'filter'
787
812
  ])
788
813
 
789
- function queriesNested(declarations, uuids = null) {
814
+ function queriesNested(declarations, uuids = null, org = null) {
790
815
  const out = []
791
816
  for (const [name, d] of Object.entries(declarations)) {
817
+ refuseUnder(d.where, `queries.${name}`)
792
818
  const data = {}
793
819
  const source = d.path ? { path: d.path } : d.url ? { url: d.url } : d.source
794
820
  setIf(data, 'source', source)
795
- setIf(data, 'schema', d.schema)
821
+ // ⛔ QUALIFIED, WITH THE SAME RULE AND THE SAME ORG AS THE RECORDS' `$model`
822
+ // (`records.js::buildRecordEntities`). A consumer answers a query by matching
823
+ // this name against the Models its records were stored under, so a verbatim
824
+ // `@/member` beside records stored as `@org/member` names nothing: the query
825
+ // resolves no Model and the page that binds it renders empty. The pull puts the
826
+ // author's `@/` back (`records-project.js::declarationsToQueriesYml`).
827
+ setIf(data, 'schema', resolveSelfScope(d.schema, org))
796
828
  setIf(data, 'sort', d.sort)
797
829
  // Legacy `filter:` is not synced — it is translated to `where` upstream
798
830
  // (the canonical predicate). No legacy fields on the wire.
@@ -1021,7 +1053,7 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1021
1053
  // `ogTitle`, `ogDescription`, `noindex`, `canonical`, `changefreq`, `priority`
1022
1054
  // (`core/src/seo.js`). Two of those are literally sitemap.xml columns. It only
1023
1055
  // ever passed the card test because `image` was inside it; the card's picture is
1024
- // `info.preview_image` now.
1056
+ // `info.preview` now.
1025
1057
  setIf(settings, 'seo', siteYml.seo)
1026
1058
  // ⭐ `keywords` IS seo by function — it renders into `<meta name="keywords">`
1027
1059
  // (`runtime/src/ssr-renderer.js`). It is top-level in site.yml for authoring
@@ -1051,6 +1083,10 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1051
1083
  // ⭐ The site-level fetch, DESUGARED and under its real name. `data:` is the
1052
1084
  // authoring shorthand for `fetch:` and every other tier already calls the wire
1053
1085
  // field `fetch`; the site tier called it `data` until 2026-09-09.
1086
+ // `under` is refused as the build refuses it; the `data:` shorthand carries no
1087
+ // `where`. ⚠️ The source expression stays inside `setIf`: `gen-emit-surface.mjs`
1088
+ // reads the published key's sources off it.
1089
+ for (const one of [siteYml.fetch].flat()) refuseUnder(one?.where, 'site.yml fetch')
1054
1090
  setIf(settings, 'fetch', siteYml.fetch ?? fetchFromDataShorthand(siteYml.data))
1055
1091
 
1056
1092
  // ⭐ The SITE TIER of framework's own `{name, hide, params}` layout object, which
@@ -1076,6 +1112,9 @@ function settingsNested(siteYml, { headHtml, themeYml, sourceLocale, translation
1076
1112
  * @param {string} [opts.sourceLocale] - localized-field wrap locale. Defaults to
1077
1113
  * the site's effective default locale (`defaultLanguage || languages[0] ||
1078
1114
  * 'en'` — the shared `resolveDefaultLocale` rule), NOT a bare 'en'.
1115
+ * @param {string} [opts.org] - the publish org, which qualifies a query's
1116
+ * foundation-relative `schema` (`@/x` → `@org/x`). Pass the same org the
1117
+ * records are emitted with; absent, `@/x` ships as written.
1079
1118
  * @returns {Promise<object>} the section-keyed `$`-document:
1080
1119
  * `{ $uuid?, $id, $model, info, pages, layout_sections, extensions, queries }`
1081
1120
  */
@@ -1271,11 +1310,28 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1271
1310
  // over an already-fetched list and never asks the database, while a DB-filterable
1272
1311
  // facet is separately useful — only the second needs a predicable brief field.
1273
1312
  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.
1313
+ // `preview` the site card's image. ONE field, TWO writers, and it round-trips
1314
+ // whichever wrote it [Diego, 2026-09-10]:
1315
+ // · the APP, when it generates a card image a timestamp, carried verbatim;
1316
+ // · an AUTHOR, deliberately typically a template site's custom image: a URL, or
1317
+ // a site-root path to an image in the project, which push uploads like any
1318
+ // content image and sends as its serve URL, and which pull puts back as the
1319
+ // path the author wrote (`restoreAssetRefs` recognizes the URL by the
1320
+ // fingerprint `assets.json` recorded for it).
1321
+ // The app leaves an author's value alone.
1322
+ //
1323
+ // ⚠️ This read "`url` and `preview_image` are BACKEND-STAMPED and framework emits
1324
+ // NEITHER" until 2026-09-10. Neither half held: nothing stamped `url`, and leaving
1325
+ // an app-written field off an allowlist destroys it on every push, because `info`
1326
+ // is replaced whole.
1327
+ setIf(info, 'preview', scalarString(siteYml.preview))
1328
+ // ⭐ `url` — where the site is live, so a site card can link to it without opening
1329
+ // the editor. The backend records it at every publish (stated by backend,
1330
+ // 2026-09-10) and pull brings it into `site.yml::$url`; nothing in framework writes
1331
+ // it. The `$` marks it as recorded rather than authored, like `$uuid` — and keeps it
1332
+ // out of the rendered payload, where a bare `url:` would sit beside `seo.baseUrl`,
1333
+ // the authored canonical address.
1334
+ setIf(info, 'url', siteYml.$url)
1279
1335
 
1280
1336
  const ctx = { siteRoot, siteIndex: siteYml.index, sourceLocale, translations }
1281
1337
  const pagesPath = siteYml.paths?.pages
@@ -1327,7 +1383,7 @@ export async function siteProjectToDocument(siteRoot, opts = {}) {
1327
1383
  //
1328
1384
  // ⚠️ `queriesNested` keeps its name. §2's rule: rename what an author or a
1329
1385
  // consumer sees, leave the identifier alone.
1330
- doc.queries = queriesNested(colConfig.declarations, opts.queryUuids)
1386
+ doc.queries = queriesNested(colConfig.declarations, opts.queryUuids, opts.org)
1331
1387
  // Emitted ONLY when the file declares the key — see the header above
1332
1388
  // `serviceRecords`: on a replaced Section, absent and empty are different
1333
1389
  // 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
+ }