@uniweb/build 0.37.0 → 0.38.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.
@@ -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, queryDataUrl } from '@uniweb/core'
23
+ import { matchWhere, sortRecords, queryDataUrl } from '@uniweb/core'
24
24
 
25
25
  /**
26
26
  * Infer schema name from path or URL
@@ -128,42 +128,31 @@ export function applyFilter(items, filterExpr) {
128
128
  }
129
129
 
130
130
  /**
131
- * Apply sort expression to array of items
131
+ * Apply a `sort:` to an array of items — `@uniweb/core`'s ONE evaluator, the
132
+ * same the runtime's fallback runs, so a query orders identically on the file
133
+ * lane and over a fetched array.
134
+ *
135
+ * ⛔ SINGLE-KEY, BY RULING [Diego, 2026-09-04]. This was its own implementation
136
+ * until then, and it honoured `order asc, title asc` — a multi-key sort the
137
+ * records door refuses and the ruling dropped. A comma now THROWS here, at build
138
+ * time, which is where an authoring error on the file lane belongs.
132
139
  *
133
140
  * @param {Array} items - Items to sort
134
- * @param {string} sortExpr - Sort expression (e.g., "date desc" or "order asc, title asc")
141
+ * @param {string} sortExpr - Sort expression: `date`, `date desc`, `-date`
135
142
  * @returns {Array} Sorted items (new array)
136
- *
137
- * @example
138
- * applySort(items, 'date desc')
139
- * applySort(items, 'order asc, title asc')
140
143
  */
141
144
  export function applySort(items, sortExpr) {
142
145
  if (!sortExpr || !Array.isArray(items)) return items
143
-
144
- const sorts = sortExpr.split(',').map(s => {
145
- const [field, dir = 'asc'] = s.trim().split(/\s+/)
146
- return { field, desc: dir.toLowerCase() === 'desc' }
147
- })
148
-
149
- return [...items].sort((a, b) => {
150
- for (const { field, desc } of sorts) {
151
- const aVal = getNestedValue(a, field) ?? ''
152
- const bVal = getNestedValue(b, field) ?? ''
153
- if (aVal < bVal) return desc ? 1 : -1
154
- if (aVal > bVal) return desc ? -1 : 1
155
- }
156
- return 0
157
- })
146
+ return sortRecords(items, sortExpr)
158
147
  }
159
148
 
160
149
  /**
161
150
  * Apply a where-object predicate to an array of items.
162
151
  *
163
- * The where-object is the new query language (see @uniweb/core's
164
- * matchWhere). Structured JSON predicate; the runtime evaluator walks
165
- * the object against each record. Same shape ships to backends that
166
- * declare `supports: [where]`.
152
+ * The where-object is the query language (see @uniweb/core's
153
+ * matchWhere). Structured JSON predicate; the one evaluator walks the
154
+ * object against each record, here at build time and in the runtime
155
+ * alike. The same shape crosses to a host's question door unchanged.
167
156
  *
168
157
  * @param {Array} items - Items to filter
169
158
  * @param {object} where - Where-object predicate
@@ -252,22 +241,39 @@ export function applyPostProcessing(data, config) {
252
241
  // once per key name per process so a 200-record build does not print 200 lines.
253
242
  const RECOGNIZED_FETCH_KEYS = {
254
243
  refine: new Set(['refine', 'detail', 'limit', 'sort', 'where', 'filter']),
244
+ // ⛔ `schema` IS NOT ON EITHER LIST, and its absence is the point. It was the
245
+ // binding key until 2026-09-02 and stopped being READ on 2026-09-03 (`e4fe077`,
246
+ // one name no alias) — but it was left on these lists, which exempted it from
247
+ // the very report this table exists to produce. So the retired spelling was
248
+ // dropped in the one way the author could not see: no warning, and a plausible
249
+ // key inferred from the path in its place. It has its own message below, since
250
+ // "unrecognized" understates a key that used to work.
255
251
  query: new Set([
256
- 'query', 'as', 'schema', 'prerender', 'merge', 'transform',
252
+ 'query', 'as', 'prerender', 'merge', 'transform',
257
253
  'where', 'limit', 'sort', 'detailPage', 'filter',
258
254
  ]),
259
255
  source: new Set([
260
- 'path', 'url', 'as', 'schema', 'prerender', 'merge', 'transform', 'detail',
256
+ 'path', 'url', 'as', 'prerender', 'merge', 'transform', 'detail',
261
257
  'detailPage', 'where', 'limit', 'sort', 'filter',
262
258
  ]),
263
259
  }
264
260
 
261
+ // Keys that are neither recognized nor merely unknown: they USED to work, and a
262
+ // generic "unrecognized key" line understates that. Each has a dedicated message
263
+ // naming its replacement, so this table only has to keep the generic report from
264
+ // firing a second, vaguer time on the same key.
265
+ //
266
+ // ⛔ This is not the recognized list wearing another name. A key here is still
267
+ // dropped from the parsed config; what it buys is a better sentence.
268
+ const RETIRED_FETCH_KEYS = new Set(['schema'])
269
+
265
270
  const warnedUnknownFetchKeys = new Set()
266
271
 
267
272
  function warnUnknownFetchKeys(fetch, shape) {
268
273
  const recognized = RECOGNIZED_FETCH_KEYS[shape]
269
274
  for (const key of Object.keys(fetch)) {
270
275
  if (recognized.has(key)) continue
276
+ if (RETIRED_FETCH_KEYS.has(key)) continue
271
277
  const seenKey = `${shape}:${key}`
272
278
  if (warnedUnknownFetchKeys.has(seenKey)) continue
273
279
  warnedUnknownFetchKeys.add(seenKey)
@@ -406,6 +412,7 @@ export function parseFetchConfig(fetch) {
406
412
  if (fetch.query) {
407
413
  warnUnknownFetchKeys(fetch, 'query')
408
414
  if (fetch.filter !== undefined) warnFilterDeprecated()
415
+ warnSchemaRetired(fetch, fetch.as || fetch.query)
409
416
  return {
410
417
  // ⭐ **`query` IS EMITTED, and that is what makes the two producers agree.**
411
418
  // The sync lane has always emitted it (`uwx/site.js`) and this one did not,
@@ -470,6 +477,7 @@ export function parseFetchConfig(fetch) {
470
477
  if (!path && !url) return null
471
478
 
472
479
  if (filter !== undefined) warnFilterDeprecated()
480
+ warnSchemaRetired(fetch, as ?? inferSchemaFromPath(path || url))
473
481
 
474
482
  return {
475
483
  path,
@@ -493,6 +501,46 @@ export function parseFetchConfig(fetch) {
493
501
  }
494
502
  }
495
503
 
504
+ /**
505
+ * Report a fetch still authored with the retired `schema:` binding key.
506
+ *
507
+ * ⭐ **It names the key the fetch ACTUALLY bound to, and that is the whole
508
+ * value of this message.** `schema:` is not read (ruling 2026-09-03, `e4fe077`):
509
+ * the binding key falls back to the query name or to `inferSchemaFromPath`, so
510
+ * the data still arrives — under a *different* `content.data` key. The component
511
+ * reads `?.weather`, gets `undefined`, and renders empty with nothing anywhere
512
+ * saying why. A bare "unrecognized key" would not close that gap; the inferred
513
+ * name does, because the reader can see at once whether it happens to match.
514
+ *
515
+ * ⚠️ Measured 2026-09-03, `templates/dynamic`: five of six sections rendered
516
+ * empty this way, one of them from a URL whose last segment is empty
517
+ * (`randomuser.me/api/?results=6` → `as: ''`), which is falsy and drops the
518
+ * config outright. That template shipped with no warning of any kind, because
519
+ * `schema` was left on the recognized list when it stopped being read.
520
+ *
521
+ * Once per distinct (written → bound) pair: several files each get their own
522
+ * line, one file repeated across 200 records does not.
523
+ */
524
+ const warnedRetiredSchema = new Set()
525
+ function warnSchemaRetired(fetch, boundTo) {
526
+ if (fetch?.schema === undefined) return
527
+ const wrote = String(fetch.schema)
528
+ const bound = boundTo === '' || boundTo === undefined ? '(nothing)' : String(boundTo)
529
+ const seen = `${wrote}→${bound}`
530
+ if (warnedRetiredSchema.has(seen)) return
531
+ warnedRetiredSchema.add(seen)
532
+ console.warn(
533
+ `[uniweb] fetch: 'schema: ${wrote}' is retired as the binding key and is NOT read. ` +
534
+ `This fetch binds to content.data.${bound} instead. Write 'as: ${wrote}'. ` +
535
+ "(On a `queries:` declaration `schema:` is a different, current key — the Model ref.)"
536
+ )
537
+ }
538
+
539
+ /** Test seam — reset the retired-`schema:` memo so suites do not leak into each other. */
540
+ export function _resetRetiredSchemaWarnings() {
541
+ warnedRetiredSchema.clear()
542
+ }
543
+
496
544
  let filterDeprecationWarned = false
497
545
  function warnFilterDeprecated() {
498
546
  if (filterDeprecationWarned) return
@@ -505,6 +553,98 @@ function warnFilterDeprecated() {
505
553
  )
506
554
  }
507
555
 
556
+ /**
557
+ * Keys a fetch declaration carries for THE BUILD ONLY, which no runtime reads.
558
+ *
559
+ * `merge` decides how a section-level fetch lands in `parsedContent.data` when
560
+ * prerender (or the dev server) executes it — a build-lane feature, documented as
561
+ * such. It rode every shipped payload regardless, and a key on the payload that
562
+ * nothing reads is a key a consumer will one day read. Stripped at the two emit points framework owns — the link lane's
563
+ * `site-content.json` and the bundle lane's embed — AFTER the build has consumed
564
+ * it. ⛔ Not from the sync wire: that carries the author's declaration, which
565
+ * `pull` must round-trip.
566
+ */
567
+ const BUILD_ONLY_FETCH_KEYS = ['merge']
568
+
569
+ function stripFetch(fetch) {
570
+ if (!fetch || typeof fetch !== 'object') return fetch
571
+ if (Array.isArray(fetch)) return fetch.map(stripFetch)
572
+ let changed = false
573
+ const out = {}
574
+ for (const [key, value] of Object.entries(fetch)) {
575
+ if (BUILD_ONLY_FETCH_KEYS.includes(key)) {
576
+ changed = true
577
+ continue
578
+ }
579
+ out[key] = value
580
+ }
581
+ return changed ? out : fetch
582
+ }
583
+
584
+ function stripSections(sections) {
585
+ if (!Array.isArray(sections)) return sections
586
+ return sections.map((section) => {
587
+ if (!section || typeof section !== 'object') return section
588
+ const fetch = stripFetch(section.fetch)
589
+ const subsections = stripSections(section.subsections)
590
+ if (fetch === section.fetch && subsections === section.subsections) return section
591
+ const out = { ...section }
592
+ if (fetch !== section.fetch) out.fetch = fetch
593
+ if (subsections !== section.subsections) out.subsections = subsections
594
+ return out
595
+ })
596
+ }
597
+
598
+ function stripPageLike(page) {
599
+ if (!page || typeof page !== 'object') return page
600
+ const fetch = stripFetch(page.fetch)
601
+ const sections = stripSections(page.sections)
602
+ if (fetch === page.fetch && sections === page.sections) return page
603
+ const out = { ...page }
604
+ if (fetch !== page.fetch) out.fetch = fetch
605
+ if (sections !== page.sections) out.sections = sections
606
+ return out
607
+ }
608
+
609
+ /**
610
+ * A copy of a site-content payload with the build-only fetch keys removed from
611
+ * every fetch declaration it carries: `config.fetch`, each page's, each
612
+ * section's (and subsection's), each layout area's, and the `config` inside
613
+ * `fetchedData` entries. Structural sharing — untouched objects are the same
614
+ * objects, so this is cheap on a large site.
615
+ *
616
+ * @param {Object} siteContent
617
+ * @returns {Object}
618
+ */
619
+ export function stripBuildOnlyFetchKeys(siteContent) {
620
+ if (!siteContent || typeof siteContent !== 'object') return siteContent
621
+ const out = { ...siteContent }
622
+ if (out.config && typeof out.config === 'object' && out.config.fetch !== undefined) {
623
+ const fetch = stripFetch(out.config.fetch)
624
+ if (fetch !== out.config.fetch) out.config = { ...out.config, fetch }
625
+ }
626
+ if (Array.isArray(out.pages)) out.pages = out.pages.map(stripPageLike)
627
+ if (out.layouts && typeof out.layouts === 'object') {
628
+ const layouts = {}
629
+ for (const [name, areas] of Object.entries(out.layouts)) {
630
+ if (!areas || typeof areas !== 'object') { layouts[name] = areas; continue }
631
+ const next = {}
632
+ for (const [area, page] of Object.entries(areas)) next[area] = stripPageLike(page)
633
+ layouts[name] = next
634
+ }
635
+ out.layouts = layouts
636
+ }
637
+ if (out.notFound) out.notFound = stripPageLike(out.notFound)
638
+ if (Array.isArray(out.fetchedData)) {
639
+ out.fetchedData = out.fetchedData.map((entry) => {
640
+ if (!entry || typeof entry !== 'object') return entry
641
+ const config = stripFetch(entry.config)
642
+ return config === entry.config ? entry : { ...entry, config }
643
+ })
644
+ }
645
+ return out
646
+ }
647
+
508
648
  /**
509
649
  * Execute a fetch operation
510
650
  *
@@ -33,7 +33,7 @@
33
33
  import { resolve, join } from 'node:path'
34
34
  import { watch, existsSync } from 'node:fs'
35
35
  import { readFile, readdir } from 'node:fs/promises'
36
- import { resolveDefaultLocale, DATA_DIR } from '@uniweb/core'
36
+ import { resolveDefaultLocale, resolveFetchConfigs, DATA_DIR } from '@uniweb/core'
37
37
  import {
38
38
  renderSiteIndex,
39
39
  renderPageMarkdown,
@@ -136,13 +136,24 @@ export function shouldPrefetchInDev(cfg) {
136
136
  async function executeDevFetches(siteContent, siteDir) {
137
137
  const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
138
138
  const fetchedData = []
139
+ // Resolved the way the runtime resolves it (see prerender.js::executeAllFetches
140
+ // for why): the SPA hydrates by the cache key of ITS resolved config.
141
+ const resolveOptions = {
142
+ locale: siteContent.config?.activeLocale ?? null,
143
+ defaultLocale: resolveDefaultLocale(siteContent.config) ?? null,
144
+ queries: siteContent.config?.queries ?? null,
145
+ records: null,
146
+ }
147
+ const resolveForDev = (one) => resolveFetchConfigs([one], resolveOptions).get(one.as) ?? one
148
+ const entry = (cfg, data) => ({ config: cfg, data, meta: { depth: cfg.depth } })
139
149
 
140
150
  // Site-level fetch — every declaration.
141
151
  for (const siteFetch of toFetchList(siteContent.config?.fetch)) {
142
152
  if (!shouldPrefetchInDev(siteFetch)) continue
143
- const result = await executeFetch(siteFetch, fetchOptions)
153
+ const cfg = resolveForDev(siteFetch)
154
+ const result = await executeFetch(cfg, fetchOptions)
144
155
  if (result.data && !result.error) {
145
- fetchedData.push({ config: siteFetch, data: result.data })
156
+ fetchedData.push(entry(cfg, result.data))
146
157
  }
147
158
  }
148
159
 
@@ -151,9 +162,10 @@ async function executeDevFetches(siteContent, siteDir) {
151
162
  // Page-level fetch — every declaration.
152
163
  for (const pageFetch of toFetchList(page.fetch)) {
153
164
  if (!shouldPrefetchInDev(pageFetch)) continue
154
- const result = await executeFetch(pageFetch, fetchOptions)
165
+ const cfg = resolveForDev(pageFetch)
166
+ const result = await executeFetch(cfg, fetchOptions)
155
167
  if (result.data && !result.error) {
156
- fetchedData.push({ config: pageFetch, data: result.data })
168
+ fetchedData.push(entry(cfg, result.data))
157
169
  }
158
170
  }
159
171
 
@@ -8,8 +8,7 @@
8
8
  // took `site.yml`'s values and sync took `collections.yml`'s, so an author writing
9
9
  // `sort: date desc` here got `date asc` baked into the static file.
10
10
  //
11
- // The broken case was the one the public docs recommend. See
12
- // `kb/framework/plans/one-collections-config.md`.
11
+ // The broken case was the one the public docs recommend.
13
12
  //
14
13
  // ⭐ A QUERY IS SECOND-ORDER SITE CONTENT — it describes how to REACH content, and
15
14
  // is evaluated rather than rendered. `queries.yml` is a BARE MAP of name → query at
@@ -19,7 +18,7 @@
19
18
  // ⛔ THE THREE JOBS `collections/<name>/` USED TO FUSE ARE NOW THREE THINGS.
20
19
  // `entities/{schema}/` is the pool, `records.yml` is the folder (what makes an
21
20
  // entity a record), and a query asks the folder for a set. This file resolves the
22
- // LAST of those only. Model: `kb/framework/plans/records-model.md`.
21
+ // LAST of those only.
23
22
  //
24
23
  // ⚠️ `collections.yml` and `site.yml::collections` are GONE, with no alias and no
25
24
  // deprecation path — the model's §5 ruling, and there is nothing outside this
@@ -54,7 +54,7 @@ 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 } from '@uniweb/core'
57
+ import { DATA_DIR, fillRoutePattern } from '@uniweb/core'
58
58
  import { applyWhere, applyFilter, applySort } from './data-fetcher.js'
59
59
  import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
60
60
  import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
@@ -675,13 +675,30 @@ async function collectItems(siteDir, config, entitiesDir, basePath) {
675
675
 
676
676
  warnDuplicateSlugs(items, config.name)
677
677
 
678
- // Add routes to items if collection has a route configured
678
+ // `route:` on the query bake each record's canonical href.
679
+ //
680
+ // ⭐ THROUGH THE ONE ENCODER (`fillRoutePattern`, `@uniweb/core/route-match`),
681
+ // which is what the runtime's `addDetailRoute` also calls. Until 2026-09-04 this
682
+ // interpolated `${baseRoute}/${item.slug}` RAW while the runtime encoded, and a
683
+ // record already carrying a baked route keeps it — so the same record got two
684
+ // different hrefs depending on which lane served it (F14): a slug with a space
685
+ // compared unequal to `location.pathname`, and a slug with a `/` became an
686
+ // extra route segment. A record with no slug gets no route rather than
687
+ // `/blog/undefined`.
688
+ //
689
+ // `route: /blog` names the base of a `[slug]` page, so the template is
690
+ // `/blog/:slug`. `route: /blog/[...path]` names a `[...path]` page: the
691
+ // template is `/blog/:path*` and the record's placement (`path`, the folder
692
+ // `records.yml` put it in) becomes part of its href — `/blog/field/my-post`.
679
693
  if (config.route) {
680
- const baseRoute = config.route.replace(/\/$/, '') // Remove trailing slash
681
- items = items.map(item => ({
682
- ...item,
683
- route: `${baseRoute}/${item.slug}`
684
- }))
694
+ const base = config.route.replace(/\/$/, '')
695
+ const template = base.endsWith('/[...path]')
696
+ ? `${base.slice(0, -'/[...path]'.length)}/:path*`
697
+ : `${base}/:slug`
698
+ items = items.map((item) => {
699
+ const route = fillRoutePattern(template, item)
700
+ return route === null ? item : { ...item, route }
701
+ })
685
702
  }
686
703
 
687
704
  // ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where, filter, sort,
@@ -42,7 +42,7 @@
42
42
  // fill it in) is guarded at the CLI with a count and a confirmation; the format
43
43
  // stays honest and the CLI does the asking.
44
44
  //
45
- // Model: `kb/framework/plans/records-model.md`.
45
+ // Model: entity · record · query · folder.
46
46
 
47
47
  import { existsSync } from 'node:fs'
48
48
  import { readFile } from 'node:fs/promises'
@@ -4,9 +4,10 @@
4
4
  // The entity has FOUR Sections — decompose only what a consumer needs to read
5
5
  // on its own; keep coarse what is shipped whole:
6
6
  //
7
- // info single, brief — identity ONLY: name, version, role, description.
8
- // Field-decomposed so identity is readable without
9
- // opening the rest. This is the summary card.
7
+ // info single, brief — identity: name, version, role, description, plus the
8
+ // producer statements a consumer must act on without
9
+ // opening the rest (`digest`, `runtime`, `supports`).
10
+ // Field-decomposed. This is the summary card.
10
11
  // schema single — ONE opaque `schema` json field: the whole renderable
11
12
  // schema.json MINUS identity and MINUS dataSchemas
12
13
  // (components, layouts, outputs, plus foundation-wide config
@@ -84,6 +85,11 @@ export function foundationSchemaToEntity(schema, opts = {}) {
84
85
  role: self.role || 'foundation',
85
86
  }
86
87
  if (self.description !== undefined) info.description = self.description
88
+ // The host services this foundation is built against (package.json's
89
+ // `uniweb.supports`). `Array.isArray`, not truthiness: `[]` is an explicit
90
+ // "none"; an ABSENT key means UNKNOWN. Mirrors `buildInfo` in
91
+ // `registry-package.js`, which is the path `uniweb register` actually takes.
92
+ if (Array.isArray(self.supports)) info.supports = self.supports
87
93
 
88
94
  // ── schema — the whole renderable schema.json minus identity and minus
89
95
  // dataSchemas, shipped WHOLE as one opaque blob. ───────────────────────────
@@ -93,6 +99,7 @@ export function foundationSchemaToEntity(schema, opts = {}) {
93
99
  version: _v,
94
100
  description: _d,
95
101
  role: _r,
102
+ supports: _s,
96
103
  ...selfConfig
97
104
  } = rest._self || {}
98
105
  const schemaBlob = { ...rest, _self: selfConfig }
@@ -2,7 +2,7 @@
2
2
  // referenced BY NAME, on the entity-content SYNC lane.
3
3
  //
4
4
  // Each record becomes a section-keyed `$`-document (docs/reference/entity-content.md):
5
- // `$id` (the slug — the producer-local handle), `$model` (the Model by name), and
5
+ // `$id` (the producer-local handle), `$model` (the Model by name), and
6
6
  // each SINGLE section keyed by its name — the brief plus any sibling singles, not
7
7
  // the brief alone. The backend MINTS `$uuid` on first sync and
8
8
  // returns it in the finalized response; the verb back-fills it into the source
@@ -91,8 +91,9 @@ function stripSigils(value) {
91
91
  // ⛔ A `@uniweb/folder` REF LEAF ENCODES ONE REFERENCE TWO WAYS, and hashing the
92
92
  // encoding rather than the reference made the folder's hash unreproducible.
93
93
  //
94
- // `refLeaf` (uwx/folder.js) emits `$ref: "<collection>/<slug>"` while the record
95
- // is brand-new and `entry: { model, entity: <uuid> }` once it has been minted.
94
+ // `refLeaf` (uwx/folder.js) emits `$ref: <the record's $id>` — the pool position
95
+ // `<dirs>/<slug>` — while the record is brand-new, and `entry: { model, entity:
96
+ // <uuid> }` once it has been minted.
96
97
  // Both denote the same record. A push hashes the folder BEFORE submitting, then
97
98
  // back-fills the minted `$uuid` into every record's source file — so the very
98
99
  // next emit builds the OTHER encoding, and the hash the push just banked can
@@ -181,7 +182,7 @@ function encodeFieldValue(value, field, sourceLocale, translations) {
181
182
  * already carries `$uuid` (back-filled from a prior sync) round-trips it.
182
183
  *
183
184
  * @param {object} params
184
- * @param {string} params.queryName - the site.yml collection name
185
+ * @param {string} params.queryName - the query's name in site.yml
185
186
  * @param {object[]} params.records - [{ slug, ...fields }]
186
187
  * @param {object} params.declaration - the `@uniweb/data-schema` declaration
187
188
  * (from toDataSchemaDeclaration): `{ name, brief, sections }`
@@ -272,9 +273,22 @@ export function recordsToEntities({
272
273
  warnings.push(`${queryName}: a record without a slug was skipped`)
273
274
  continue
274
275
  }
275
- // `$id` is the payload-local handle = the record's path under collections/
276
- // (`<collection>/<slug>`), globally unique within one sync so the @uniweb/folder
277
- // entity can point a leaf at it via `$ref`. An explicit frontmatter `$id` wins.
276
+ // `$id` IS NOT THE SLUG. It is the payload-local, PATH-QUALIFIED handle, so
277
+ // the @uniweb/folder entity can point a leaf at it via `$ref`. An explicit
278
+ // frontmatter `$id` wins.
279
+ //
280
+ // ⚠️ The authoritative value is the record's POOL POSITION — `<dirs>/<slug>` —
281
+ // and it is set upstream, at the pool walk; see the ⭐ comment there, which is
282
+ // where the reasoning lives. `<query>/<slug>` below is only the fallback for a
283
+ // record that did not arrive through the pool, and it is explicitly NOT the
284
+ // shape identity is meant to take: two queries over one Model would mint two
285
+ // identities for one file.
286
+ //
287
+ // The qualification is a CONSTRAINT, not a style: the sync response is keyed per
288
+ // (`$model`, `$id`), so a bare slug would collide whenever two queries over the
289
+ // same Model reuse one (see the duplicate check below). ⇒ Do not describe this
290
+ // value as "the slug" — the folder leaf's `path_segment` is the bare segment, and
291
+ // conflating the two has already misdirected a naming decision.
278
292
  const id = record.$id || `${queryName}/${slug}`
279
293
  const uuid = record.$uuid || null
280
294
  const hasBody = typeof record.$body === 'string' && record.$body.trim() !== ''
@@ -631,7 +645,7 @@ export async function buildRecordEntities(siteRoot, opts = {}) {
631
645
  // statically (the "data ball") instead, so the caller can route them there.
632
646
  const schemaless = []
633
647
  // The sync response is keyed per ($model, $id), so the pair must be unique
634
- // within one submission (two collections on the same Model could otherwise
648
+ // within one submission (two queries over the same Model could otherwise
635
649
  // reuse a slug).
636
650
  const seen = new Set()
637
651
  for (const { name, decl } of mapped) {
@@ -159,6 +159,11 @@ function buildInfo(self, org, digest, runtime) {
159
159
  // be satisfied. Same lift as `digest`: stated by the producer, opaque to the
160
160
  // backend, acted on by whoever resolves a whole site.
161
161
  if (runtime) info.runtime = runtime
162
+ // The host services this foundation is BUILT AGAINST, from package.json's
163
+ // `uniweb.supports`. `Array.isArray` and not truthiness: `[]` is an explicit
164
+ // "none" and must survive as one, while an ABSENT key means UNKNOWN — the
165
+ // same three-state rule `runtime` states above, for the same reason.
166
+ if (Array.isArray(self.supports)) info.supports = self.supports
162
167
  return info
163
168
  }
164
169
 
@@ -166,7 +171,11 @@ function buildInfo(self, org, digest, runtime) {
166
171
  // as one opaque object the backend never reads into (custodian).
167
172
  function buildSchemaBlob(schema) {
168
173
  const { dataSchemas: _ds, ...rest } = schema
169
- const { name: _n, version: _v, description: _d, role: _r, ...selfConfig } = rest._self || {}
174
+ // Every key hoisted into `info` is stripped here, so the wire carries each
175
+ // fact ONCE. Two copies of one fact is a drift liability, and the copy inside
176
+ // an opaque blob is the one nobody would think to update.
177
+ const { name: _n, version: _v, description: _d, role: _r, supports: _s, ...selfConfig } =
178
+ rest._self || {}
170
179
  return { ...rest, _self: selfConfig }
171
180
  }
172
181
 
@@ -513,7 +513,12 @@ function projectPages(pages, pagesDir, sourceLocale, report, prune, ctx) {
513
513
  // is already a plain string.
514
514
  export function pageDirName(record, sourceLocale) {
515
515
  const slug = unwrapLocalized(record.slug, sourceLocale)
516
- return record.is_dynamic ? `[${record.param_name || slug}]` : slug
516
+ if (!record.is_dynamic) return slug
517
+ // The multi-segment folder rides the wire as slug `...path` with
518
+ // `param_name: slug` (the handle it delivers by); it comes back as the one
519
+ // fixed spelling, never as `[slug]`.
520
+ if (slug === '...path') return '[...path]'
521
+ return `[${record.param_name || slug}]`
517
522
  }
518
523
 
519
524
  // Pass 1 — write + relocate every page dir, its page.yml/folder.yml, and its
package/src/uwx/site.js CHANGED
@@ -334,6 +334,12 @@ async function orderedSubfolders(dirPath, inheritedMode, parentConfig) {
334
334
  }
335
335
 
336
336
  const DYNAMIC_RE = /^\[(.+)\]$/
337
+ // The multi-segment route folder, one fixed spelling (`content-collector.js`).
338
+ // On the wire its page `slug` is the marker itself (`...path`) and its
339
+ // `param_name` is `slug`: the record is delivered by its handle, the last
340
+ // segment, exactly as under `[slug]`. ⚠️ What a consumer's projector emits as
341
+ // the page ROUTE for it is that consumer's; framework expects `/…/:path*`.
342
+ const CATCH_ALL_MARKER = '...path'
337
343
 
338
344
  // ===========================================================================
339
345
  // NESTED ($-document) lane — Phase 0 de-flatten (bidirectional-sync §8).
@@ -545,7 +551,7 @@ async function walkPagesNested(ctx, dirPath, parentSlugPath, inheritedMode, pare
545
551
  slug,
546
552
  mode,
547
553
  isDynamic: !!dyn,
548
- paramName: dyn ? dyn[1] : undefined,
554
+ paramName: dyn ? (dyn[1] === CATCH_ALL_MARKER ? 'slug' : dyn[1]) : undefined,
549
555
  isRoot,
550
556
  siteIndex,
551
557
  sourceLocale,
@@ -684,7 +690,7 @@ export function isSiteRelativeExtensionUrl(decl) {
684
690
  * declaration could occupy, nothing is ever recorded for one, and every push re-sent
685
691
  * this whole section uuid-less. The backend refuses that (an all-blank section over
686
692
  * stored items would delete every stored row), which is why `push` worked once and
687
- * every push after it was refused. Measured 2026-08-29; collab framework-backend-812b.
693
+ * every push after it was refused. Measured 2026-08-29; collab frameworkbackend.
688
694
  *
689
695
  * ⭐ `name` is the right key and not merely the available one — the backend enforces
690
696
  * `unique_field(name, scope: section)` on this section, and it is the join key its
@@ -724,8 +730,6 @@ export function isSiteRelativeExtensionUrl(decl) {
724
730
  * `site.yml collections.<name>.label`; no such field has ever existed, and they have
725
731
  * corrected it.
726
732
  *
727
- * ⇒ Full record, including what is established vs merely claimed:
728
- * `kb/framework/build/collections-decl-open-questions.md`.
729
733
  *
730
734
  * @param {object} declarations resolved collection declarations, keyed by name
731
735
  * @param {Object<string,string>} [uuids] `name` → backend `$uuid`, from a push