@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.
@@ -30,7 +30,7 @@ import yaml from 'js-yaml'
30
30
  import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
31
31
  import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
32
32
  import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
33
- import { parseFetchConfig, toFetchList } from './data-fetcher.js'
33
+ import { parseFetchConfig } from './data-fetcher.js'
34
34
  import { resolveExtensionUrls } from './extension-urls.js'
35
35
  import { buildTheme, extractFoundationVars } from '../theme/index.js'
36
36
  import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
@@ -122,6 +122,51 @@ function extractRouteParam(folderName) {
122
122
  return match ? match[1] : null
123
123
  }
124
124
 
125
+ /**
126
+ * Folders the build refuses on a route, ruled 2026-09-11 [Diego]:
127
+ *
128
+ * - `[dir]` and `[path]` — `:dir` and `:path` are route variables every
129
+ * parametric page already has, so a folder by either name would make one
130
+ * name mean two values (and `[path]` is most often a mistyped `[...path]`);
131
+ * - any folder inside a `[...path]` folder — the catch-all takes the rest of
132
+ * the URL, so a page below it has a route (`/docs/:path*\/edit`) that can
133
+ * never match. A folder that holds something other than a page is named
134
+ * with a leading `_`, which the walk skips.
135
+ *
136
+ * @param {string} name - the folder's name
137
+ * @param {string} parentRoute - the route of the folder it sits in
138
+ */
139
+ function assertRouteFolder(name, parentRoute) {
140
+ if (name === '[dir]' || name === '[path]') {
141
+ const hint = name === '[path]' ? ' Did you mean `[...path]`, which captures a path of any depth?' : ''
142
+ throw new Error(
143
+ `[uniweb] pages: a folder cannot be named \`${name}\` — \`:${name.slice(1, -1)}\` is a route ` +
144
+ `variable every parametric page already has.${hint}`
145
+ )
146
+ }
147
+ if (typeof parentRoute === 'string' && /\/:[A-Za-z0-9_-]+\*(\/|$)/.test(parentRoute)) {
148
+ throw new Error(
149
+ `[uniweb] pages: \`${name}\` sits inside a \`[...path]\` folder (${parentRoute}). The catch-all ` +
150
+ `takes the rest of the URL, so a page below it could never be reached. Move it beside the ` +
151
+ `\`[...path]\` folder, or name it \`_${name}\` if it holds something other than a page.`
152
+ )
153
+ }
154
+ }
155
+
156
+ /**
157
+ * The route param a page nested inside a parametric page binds — its nearest
158
+ * parametric ancestor's, the DEEPEST `:param` of the route it sits under. Null
159
+ * when no ancestor is parametric.
160
+ *
161
+ * @param {string} parentRoute
162
+ * @returns {string|null}
163
+ */
164
+ function inheritedRouteParam(parentRoute) {
165
+ if (typeof parentRoute !== 'string') return null
166
+ const params = [...parentRoute.matchAll(/:([A-Za-z0-9_-]+)(?=\/|$)/g)].map((m) => m[1])
167
+ return params.length ? params[params.length - 1] : null
168
+ }
169
+
125
170
  // ─────────────────────────────────────────────────────────────────
126
171
  // Version Detection
127
172
  // ─────────────────────────────────────────────────────────────────
@@ -866,7 +911,6 @@ async function processFileAsPage(filePath, fileName, siteRoot, parentRoute) {
866
911
  lastModified: fileStat.mtime?.toISOString() || null,
867
912
  isDynamic: false,
868
913
  paramName: null,
869
- parentSchema: null,
870
914
  version: null,
871
915
  versionMeta: null,
872
916
  versionScope: null,
@@ -1467,12 +1511,19 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1467
1511
  // Determine route
1468
1512
  // Index pages get the parent route as their canonical route (no dual routes)
1469
1513
  // sourcePath stores the original folder-based path for ancestor checking
1470
- const isDynamic = isDynamicRoute(pageName)
1471
- const paramName = isDynamic ? extractRouteParam(pageName) : null
1514
+ // A page is PARAMETRIC when its folder is a bracket name or it sits inside
1515
+ // one (ruled 2026-09-11 [Diego]): `pages/members/[slug]/cv/` is `/members/:slug/cv`
1516
+ // and binds its ancestor's `slug`. Every lane — the SPA, the prefetch, the static
1517
+ // build — tells a parametric page by the parameter in its route; this flag says
1518
+ // the same thing to a consumer that reads the flag.
1519
+ const isBracket = isDynamicRoute(pageName)
1520
+ const inheritedParam = isBracket ? null : inheritedRouteParam(parentRoute)
1521
+ const isDynamic = isBracket || inheritedParam !== null
1522
+ const paramName = isBracket ? extractRouteParam(pageName) : inheritedParam
1472
1523
 
1473
1524
  // First, calculate the folder-based route (what the route would be without index handling)
1474
1525
  let folderRoute
1475
- if (isDynamic) {
1526
+ if (isBracket) {
1476
1527
  // Dynamic routes: /blog/[slug] → /blog/:slug (for route matching);
1477
1528
  // /blog/[...path] → /blog/:path* — the one multi-segment token the matcher knows.
1478
1529
  const token = isCatchAllRoute(pageName) ? ':path*' : `:${paramName}`
@@ -1500,23 +1551,13 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1500
1551
  const layoutObj = mergeLayoutConfig(inheritedLayout, normalizeLayoutConfig(layoutConfig))
1501
1552
  const resolvedLayoutName = layoutObj.name || null
1502
1553
 
1503
- // For dynamic routes, determine the parent's data schema this tells
1504
- // prerender which data array to iterate over.
1505
- //
1506
- // ⚖️ **A `[slug]` template expands over exactly ONE record set**, so a plural
1507
- // parent declaration has to resolve to one query here. The first is taken,
1508
- // matching what prerender records in `pageFetchedData`; the two must agree or
1509
- // expansion iterates a set the route was not built from.
1510
- //
1511
- // ⛔ This is a genuine cardinality constraint, not a limit worth lifting: a
1512
- // route pattern names one variable, and "which collection does `:slug` index"
1513
- // has no second answer. A page that needs another dataset alongside its
1514
- // dynamic one still declares it — plurality is what makes that sayable.
1515
- let parentSchema = null
1516
- if (isDynamic && parentFetch) {
1517
- const [first] = toFetchList(parentFetch)
1518
- parentSchema = first ? first.as : null
1519
- }
1554
+ // NO `parentSchema`. Which query a parametric page's URL names one record of
1555
+ // its ROUTE QUERY is worked out where it is read, by one function every lane
1556
+ // calls (`routeQuery`, `@uniweb/core/fetch-config`): the page's own query, its
1557
+ // parent's, the site's, or its sections' shared key. This emitted a copy chosen
1558
+ // by another rule (the closest ancestor with a query at ANY depth, never the
1559
+ // page's own or the site's), so the URL narrowed nothing, or a key no section
1560
+ // received measured 2026-09-10. Removed 2026-09-11 [Diego].
1520
1561
 
1521
1562
  return {
1522
1563
  page: {
@@ -1535,10 +1576,9 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
1535
1576
  : {}),
1536
1577
  lastModified: lastModified?.toISOString(),
1537
1578
 
1538
- // Dynamic route metadata
1579
+ // Parametric route metadata
1539
1580
  isDynamic,
1540
- paramName, // e.g., "slug" from [slug]
1541
- parentSchema, // e.g., "articles" - the data array to iterate over
1581
+ paramName, // e.g., "slug" from [slug]; a nested page's ancestor's
1542
1582
 
1543
1583
  // Version metadata (if within a versioned section)
1544
1584
  version: versionContext?.version || null,
@@ -1872,6 +1912,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1872
1912
  // Process subdirectories
1873
1913
  for (const folder of orderedFolders) {
1874
1914
  const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
1915
+ assertRouteFolder(entry, parentRoute)
1875
1916
  const isIndex = entry === indexName
1876
1917
  const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
1877
1918
 
@@ -1930,7 +1971,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
1930
1971
  lastModified: null,
1931
1972
  isDynamic: false,
1932
1973
  paramName: null,
1933
- parentSchema: null,
1934
1974
  version: versionContext?.version || null,
1935
1975
  versionMeta: versionContext?.versionMeta || null,
1936
1976
  versionScope: versionContext?.scope || null,
@@ -2001,6 +2041,7 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2001
2041
  // Second pass: process each page folder
2002
2042
  for (const folder of orderedFolders) {
2003
2043
  const { name: entry, path: entryPath, dirConfig, dirMode, mountedContentMode, childOrderConfig, childLayout } = folder
2044
+ assertRouteFolder(entry, parentRoute)
2004
2045
  const isIndex = entry === indexPageName
2005
2046
  const effectiveLayout = mergeLayoutConfig(parentLayout, childLayout)
2006
2047
 
@@ -2024,7 +2065,6 @@ async function collectPagesRecursive(dirPath, parentRoute, siteRoot, orderConfig
2024
2065
  lastModified: null,
2025
2066
  isDynamic: false,
2026
2067
  paramName: null,
2027
- parentSchema: null,
2028
2068
  version: versionContext?.version || null,
2029
2069
  versionMeta: versionContext?.versionMeta || null,
2030
2070
  versionScope: versionContext?.scope || null,
@@ -2835,6 +2875,7 @@ function buildRouteTranslations(pages, { defaultLocale = 'en', languages = null
2835
2875
  }
2836
2876
 
2837
2877
  export {
2878
+ assertRouteFolder,
2838
2879
  buildRouteTranslations,
2839
2880
  extractItemName,
2840
2881
  parseWildcardArray,
@@ -20,7 +20,7 @@ import { readFile } from 'node:fs/promises'
20
20
  import { join } from 'node:path'
21
21
  import { existsSync } from 'node:fs'
22
22
  import yaml from 'js-yaml'
23
- import { matchWhere, sortRecords, queryDataUrl } from '@uniweb/core'
23
+ import { matchWhere, sortRecords, queryDataUrl, applyScope } from '@uniweb/core'
24
24
 
25
25
  /**
26
26
  * Infer schema name from path or URL
@@ -115,10 +115,16 @@ export function applyWhere(items, where) {
115
115
  */
116
116
  export function applyPostProcessing(data, config) {
117
117
  if (!data || !Array.isArray(data)) return data
118
- if (!config.where && !config.sort && !config.limit) return data
118
+ if (!config.scope && !config.where && !config.sort && !config.limit) return data
119
119
 
120
120
  let result = data
121
121
 
122
+ // `scope` first — the folder branch the rest of the query reads, over each
123
+ // record's placement (`path`), as the runtime's default fetcher applies it.
124
+ if (typeof config.scope === 'string' && config.scope) {
125
+ result = applyScope(result, config.scope)
126
+ }
127
+
122
128
  // Apply where-object predicate first (new path)
123
129
  if (config.where) {
124
130
  result = applyWhere(result, config.where)
@@ -176,16 +182,50 @@ const RECOGNIZED_FETCH_KEYS = {
176
182
  // dropped in the one way the author could not see: no warning, and a plausible
177
183
  // key inferred from the path in its place. It has its own message below, since
178
184
  // "unrecognized" understates a key that used to work.
185
+ // ⭐ `scope` is recognized since 2026-09-11, when a folder branch became `scope:`
186
+ // on both lanes and `where: { path: { under } }` was retired in its favour. It
187
+ // was dropped here as "unrecognized" until then, so a page could not narrow a
188
+ // query to a branch at all.
179
189
  query: new Set([
180
190
  'query', 'as', 'prerender', 'merge', 'transform',
181
- 'where', 'limit', 'sort', 'detailPage',
191
+ 'scope', 'where', 'limit', 'sort', 'detailPage',
182
192
  ]),
183
193
  source: new Set([
184
194
  'path', 'url', 'as', 'prerender', 'merge', 'transform', 'detail',
185
- 'detailPage', 'where', 'limit', 'sort',
195
+ 'detailPage', 'scope', 'where', 'limit', 'sort',
186
196
  ]),
187
197
  }
188
198
 
199
+ /**
200
+ * ⛔ `under` IS RETIRED (2026-09-11 [Diego]) — refused, like every retired spelling
201
+ * here, because an ignored predicate is a silently wrong answer. It existed for
202
+ * `where: { path: { under: X } }`, a folder branch written before a query had
203
+ * `scope:`; a branch is `scope: X` now, on both lanes, and the evaluator no longer
204
+ * knows the operator, so a `where` still carrying it would match nothing.
205
+ *
206
+ * @param {Object|undefined} where
207
+ * @param {string} context - where the declaration sits, for the message
208
+ */
209
+ export function refuseUnder(where, context) {
210
+ const walk = (node) => {
211
+ if (Array.isArray(node)) {
212
+ node.forEach(walk)
213
+ return
214
+ }
215
+ if (!node || typeof node !== 'object') return
216
+ for (const [key, value] of Object.entries(node)) {
217
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.hasOwnProperty.call(value, 'under')) {
218
+ const instead = key === 'path' && typeof value.under === 'string'
219
+ ? `Write \`scope: ${JSON.stringify(value.under)}\` — the same folder branch, on every lane.`
220
+ : 'A folder branch is `scope:`; `under` is no longer an operator.'
221
+ throw new Error(`[uniweb] ${context}: \`where: { ${key}: { under: … } }\` is retired. ${instead}`)
222
+ }
223
+ walk(value)
224
+ }
225
+ }
226
+ walk(where)
227
+ }
228
+
189
229
  // Keys that are neither recognized nor merely unknown: they USED to work, and a
190
230
  // generic "unrecognized key" line understates that. Each has a dedicated message
191
231
  // naming its replacement, so this table only has to keep the generic report from
@@ -300,6 +340,7 @@ export function parseFetchConfig(fetch) {
300
340
  'per-instance refinement of the ancestor fetch, under its current name.'
301
341
  )
302
342
  }
343
+ refuseUnder(fetch.where, 'fetch')
303
344
 
304
345
  // Refine config: { refine: true, detail: false, limit: 3 }
305
346
  // No URL — merges with the parent fetch config at runtime; only carries
@@ -368,7 +409,8 @@ export function parseFetchConfig(fetch) {
368
409
  prerender: fetch.prerender ?? true,
369
410
  merge: fetch.merge ?? false,
370
411
  transform: fetch.transform,
371
- // Query operators
412
+ // Query operators — a fetch's own override the named query's, per field
413
+ scope: fetch.scope,
372
414
  where: fetch.where,
373
415
  limit: fetch.limit,
374
416
  sort: fetch.sort,
@@ -389,6 +431,7 @@ export function parseFetchConfig(fetch) {
389
431
  detail,
390
432
  detailPage,
391
433
  // Query operators
434
+ scope,
392
435
  where,
393
436
  limit,
394
437
  sort,
@@ -413,6 +456,7 @@ export function parseFetchConfig(fetch) {
413
456
  // Canonical detail page for a list card's href (page:<stable_id>).
414
457
  detailPage,
415
458
  // Query operators
459
+ scope,
416
460
  where,
417
461
  limit,
418
462
  sort,
@@ -54,8 +54,8 @@ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:p
54
54
  import { existsSync } from 'node:fs'
55
55
  import yaml from 'js-yaml'
56
56
  import { parseBibtex } from '@citestyle/bibtex'
57
- import { DATA_DIR, fillRoutePattern } from '@uniweb/core'
58
- import { applyWhere, applySort } from './data-fetcher.js'
57
+ import { DATA_DIR, fillRoutePattern, withoutRouteVariables } from '@uniweb/core'
58
+ import { applyWhere, applySort, refuseUnder } from './data-fetcher.js'
59
59
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
60
60
  import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
61
61
  import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
@@ -113,6 +113,7 @@ function parseQueryConfig(name, config) {
113
113
  schema: config,
114
114
  url: null,
115
115
  route: null,
116
+ scope: null,
116
117
  sort: null,
117
118
  where: null,
118
119
  filter: null,
@@ -122,6 +123,7 @@ function parseQueryConfig(name, config) {
122
123
  }
123
124
  }
124
125
 
126
+ refuseUnder(config.where, `queries.${name}`)
125
127
  return {
126
128
  name,
127
129
  // The query's schema selects its records from the pool — `entities/{schema}/`
@@ -129,6 +131,9 @@ function parseQueryConfig(name, config) {
129
131
  schema: config.schema || null,
130
132
  url: config.url || null,
131
133
  route: config.route || null,
134
+ // The folder branch the query reads (`records.yml` placement). ⛔ Not read
135
+ // here until 2026-09-11: a named query's `scope` was ignored on this lane.
136
+ scope: typeof config.scope === 'string' ? config.scope : null,
132
137
  sort: config.sort || null,
133
138
  // `where:` is the CANONICAL predicate; `filter:` is the deprecated string DSL
134
139
  // it replaced. Both are carried and both are applied below, in the same order
@@ -673,6 +678,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
673
678
  // Filter out nulls (unpublished items)
674
679
  items = items.filter(Boolean)
675
680
 
681
+ // ⭐ `$name` IS THE RECORD HANDLE ON EVERY SITE (ruled 2026-09-11 [Diego]) — the
682
+ // field a `[slug]` or `[...path]` page matches, and the one the records service
683
+ // serves. It is the record's FINAL slug: set here, after every format has been
684
+ // read and flattened, so a frontmatter `slug:` (which wins over the filename),
685
+ // a BibTeX cite key and an array-form file's own `slug` all count — exactly what
686
+ // our sync sends as the entry's name (`uwx/entity-source.js`). `slug` stays:
687
+ // foundations and templates read it.
688
+ items = items.map((item) => (
689
+ item && typeof item === 'object' && item.slug !== undefined && item.slug !== null && item.slug !== ''
690
+ ? { ...item, $name: String(item.slug) }
691
+ : item
692
+ ))
693
+
676
694
  warnDuplicateSlugs(items, config.name)
677
695
 
678
696
  // `route:` on the query — bake each record's canonical href.
@@ -711,8 +729,19 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
711
729
  // the sync wire, stored — and never applied, while the DEPRECATED one it replaced
712
730
  // worked. An author following current guidance got silence and shipped unfiltered
713
731
  // data. Pinned by `tests/collection-query-terms.test.js`.
714
- if (config.where) {
715
- items = applyWhere(items, config.where)
732
+ // ⭐ ONLY THE `where` FIXED FOR EVERY PAGE. A clause bound to the route —
733
+ // `where: { tag: :dir }` — cannot be applied to a file written once for every
734
+ // page; the runtime binds it per page (`@uniweb/core/fetch-config`,
735
+ // `resolveQuerySource`). ⛔ Until 2026-09-11 it was applied here to the literal
736
+ // `':dir'`, and the query compiled to no records (measured).
737
+ //
738
+ // ⛔ `scope` is NEVER baked, fixed or routed. The runtime applies the one that
739
+ // wins — a page fetch's own, else this query's — which is what the records
740
+ // service does. Baked here, a page's `scope:` could only narrow inside the
741
+ // query's branch on a static site and would replace it on a hosted one.
742
+ const fixed = withoutRouteVariables({ where: config.where })
743
+ if (fixed.where) {
744
+ items = applyWhere(items, fixed.where)
716
745
  }
717
746
 
718
747
  // Apply sort
@@ -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.