@uniweb/build 0.37.1 → 0.39.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'
@@ -143,7 +143,7 @@ export async function readRecordsConfig(siteRoot) {
143
143
  *
144
144
  * ⛔ ONE PLACEMENT PER ENTITY. Two entries matching one file is a hard error, not
145
145
  * a second placement. The wire could carry many-to-many — `folder.js` nests, and
146
- * placements are keyed by their `path_segment` chain — but `core/src/where.js`'s
146
+ * placements are keyed by their `name` chain — but `core/src/where.js`'s
147
147
  * `matchUnder` is STRING-ONLY, so a record with two paths would match nothing
148
148
  * under `where: { path: { under: … } }`, silently. Widening `under` is a
149
149
  * predicate the backend also evaluates natively, so it is a cross-lane change to
@@ -184,7 +184,7 @@ export function resolveFolder(entries, pool) {
184
184
  const slug = slugForEntity(entity)
185
185
  const path = pathSegs.join('/')
186
186
  placements.set(key, { entity, path, slug })
187
- return { kind: 'ref', path_segment: slug, $entityId: key }
187
+ return { kind: 'ref', name: slug, $entityId: key }
188
188
  }
189
189
 
190
190
  const resolveEntry = (entry, pathSegs, index, trail) => {
@@ -246,8 +246,13 @@ export function resolveFolder(entries, pool) {
246
246
  errors.push(`${RECORDS_YML_RELPATH}: ${where} declares a folder with no name.`)
247
247
  return []
248
248
  }
249
- const branch = { kind: 'branch', path_segment: segment }
250
- if (entry.label !== undefined && entry.label !== null) branch.name = String(entry.label)
249
+ // `name` is the handle (the URL segment, sibling-unique); `label` is the
250
+ // display text. The store renamed the pair on 2026-09-04 `path_segment` →
251
+ // `name`, and the old `name` (display) → `label` — so one word means one
252
+ // thing from records.yml (`folder:` / `label:`) to the wire to the door's
253
+ // `$name`.
254
+ const branch = { kind: 'branch', name: segment }
255
+ if (entry.label !== undefined && entry.label !== null) branch.label = String(entry.label)
251
256
  const kids = Array.isArray(entry.records) ? entry.records : []
252
257
  if (kids.length === 0) {
253
258
  warnings.push(
package/src/uwx/folder.js CHANGED
@@ -7,10 +7,18 @@
7
7
  // - `contents` is the self-nesting tree (an array), nesting via `$children` — the
8
8
  // same mechanism site-content pages/sections use. Each node holds REFERENCES,
9
9
  // never content:
10
- // - a LEAF references one record entity: `{ kind: 'ref', path_segment, ... }`
11
- // with `entry: <uuid>` once the record was minted (back-filled into its file),
10
+ // - a LEAF references one record entity: `{ kind: 'ref', name, ... }` with
11
+ // `entry: <uuid>` once the record was minted (back-filled into its file),
12
12
  // or `$ref: "<id>"` while brand-new (resolved within this payload).
13
- // - a BRANCH is a sub-folder: `{ kind: 'branch', path_segment, name?, $children }`.
13
+ // - a BRANCH is a sub-folder: `{ kind: 'branch', name, label?, $children }`.
14
+ //
15
+ // ⭐ `name` IS THE HANDLE — the URL segment, sibling-unique, the door's `$name` —
16
+ // and `label` is the display text, a localized map (`{ en: "Blog" }`). The store
17
+ // renamed the pair on 2026-09-04 (`path_segment` → `name`; the old display `name`
18
+ // → `label`); this emitter writes the new shape only and the pull reader
19
+ // (`records-project.js`) reads the new shape only. No alias on either side: the
20
+ // old key's PRESENCE was the version signal, and there is no population to
21
+ // carry.
14
22
  //
15
23
  // ⭐ THE ORGANIZATION IS AUTHORED, IN `records.yml`, AND IT IS THE ONLY SOURCE.
16
24
  // It used to be DERIVED — one branch per collection, mirroring the `collections/`
@@ -42,7 +50,7 @@ export const FOLDER_ENTITY_KEY = '@folder'
42
50
  // it currently carries the Model NAME (e.g. `@std/article`). Wire the name→uuid
43
51
  // resolution (a registry data-schema read) as a follow-up.
44
52
  function refLeaf(entity) {
45
- const leaf = { kind: 'ref', path_segment: entity.slug }
53
+ const leaf = { kind: 'ref', name: entity.slug }
46
54
  if (entity.uuid) leaf.entry = { model: entity.model, entity: entity.uuid }
47
55
  else leaf.$ref = entity.id // the payload-local handle
48
56
  return leaf
@@ -60,13 +68,16 @@ function refLeaf(entity) {
60
68
  * @param {Map<string, object>} byEntityId - record entities, keyed by pool id
61
69
  * @param {string[]} missing - collects ids that resolved to no entity
62
70
  */
63
- function contentsFromNodes(nodes, byEntityId, missing) {
71
+ function contentsFromNodes(nodes, byEntityId, missing, sourceLocale) {
64
72
  const out = []
65
73
  for (const node of nodes || []) {
66
74
  if (node.kind === 'branch') {
67
- const branch = { kind: 'branch', path_segment: node.path_segment }
68
- if (node.name !== undefined) branch.name = node.name
69
- branch.$children = contentsFromNodes(node.$children, byEntityId, missing)
75
+ const branch = { kind: 'branch', name: node.name }
76
+ // The display text is a LOCALIZED field on the wire — a `{ locale: value }`
77
+ // map, like every localized scalar this producer sends — keyed by the
78
+ // site's source locale.
79
+ if (node.label !== undefined) branch.label = { [sourceLocale]: String(node.label) }
80
+ branch.$children = contentsFromNodes(node.$children, byEntityId, missing, sourceLocale)
70
81
  out.push(branch)
71
82
  continue
72
83
  }
@@ -82,7 +93,7 @@ function contentsFromNodes(nodes, byEntityId, missing) {
82
93
 
83
94
  /**
84
95
  * Walk a folder document's `contents` tree, visiting every item with the
85
- * slash-joined `path_segment` chain that addresses it.
96
+ * slash-joined `name` chain that addresses it.
86
97
  *
87
98
  * ⛔ IT MUST RECURSE INTO `$children`. `contents` is SELF-NESTING: a walk of the
88
99
  * top level sees the branches and misses every record under them — which is 6 of
@@ -92,7 +103,7 @@ function contentsFromNodes(nodes, byEntityId, missing) {
92
103
  function walkFolderItems(contents, cb, prefix = '') {
93
104
  for (const item of contents || []) {
94
105
  if (!item || typeof item !== 'object') continue
95
- const seg = typeof item.path_segment === 'string' ? item.path_segment : null
106
+ const seg = typeof item.name === 'string' ? item.name : null
96
107
  const path = seg ? (prefix ? `${prefix}/${seg}` : seg) : prefix
97
108
  if (seg) cb(path, item)
98
109
  walkFolderItems(item.$children, cb, path)
@@ -102,8 +113,8 @@ function walkFolderItems(contents, cb, prefix = '') {
102
113
  /**
103
114
  * Harvest per-item identity from the folder document the backend returns.
104
115
  *
105
- * ⭐ THE KEY IS THE `path_segment` CHAIN, and it is the right one because the
106
- * backend's own model declares `path_segment` SIBLING-UNIQUE — so the chain is
116
+ * ⭐ THE KEY IS THE `name` CHAIN, and it is the right one because the backend's
117
+ * own model declares `name` SIBLING-UNIQUE — so the chain is
107
118
  * unique within the folder, stable across pushes, and derivable identically on
108
119
  * both sides without either lane holding the other's ids.
109
120
  *
@@ -163,9 +174,11 @@ export function stampFolderItemUuids(doc, pathToUuid = {}) {
163
174
  * @param {Record<string,string>} [params.itemUuids] - path → `$uuid`, harvested
164
175
  * from the folder document a previous push returned. Absent on a first
165
176
  * push, where every item is genuinely new.
177
+ * @param {string} [params.sourceLocale='en'] - the locale a branch `label` is
178
+ * keyed under on the wire
166
179
  * @returns {{ id, uuid, model, file, document, warnings }|null}
167
180
  */
168
- export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null }) {
181
+ export function buildFolderEntity({ recordEntities, folderNodes = [], declared, itemUuids = null, sourceLocale = 'en' }) {
169
182
  // ⛔ `missing` AND `empty` ARE DIFFERENT, AND THE ASYMMETRY IS DELIBERATE.
170
183
  //
171
184
  // no records.yml → null. INERT: nothing is sent, and the server's
@@ -186,7 +199,7 @@ export function buildFolderEntity({ recordEntities, folderNodes = [], declared,
186
199
  for (const e of recordEntities || []) byEntityId.set(e.id, e)
187
200
 
188
201
  const missing = []
189
- const contents = contentsFromNodes(folderNodes, byEntityId, missing)
202
+ const contents = contentsFromNodes(folderNodes, byEntityId, missing, sourceLocale)
190
203
  // ⚠️ `id` IS THE ENTITY'S POOL PATH, NOT A FOLDER PATH — say so, because the two
191
204
  // read identically and a reader who takes it for a placement concludes the
192
205
  // emitter is dropping a branch it never had. *(Measured 2026-08-31: the backend
@@ -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 }
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Identity & placement. A record's on-disk home is `(collection, slug)`:
8
8
  // - `slug` and `collection` come from the FOLDER document — each ref leaf is
9
- // `{ entry: { model, entity: <uuid> }, path_segment: <slug> }` inside a branch
10
- // (its `$children`) whose `path_segment` names the folder it was placed in.
9
+ // `{ entry: { model, entity: <uuid> }, name: <slug> }` inside a branch
10
+ // (its `$children`) whose `name` names the folder it was placed in.
11
11
  // The folder is the authoritative organization on a read (the record
12
12
  // document's own `$id` envelope is not guaranteed to be echoed back), with
13
13
  // the record document's `$id` (its pool identity) as a fallback when present.
@@ -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 { unwrapLocalized } from './backfill.js'
43
44
  import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations } from './locale-sync.js'
44
45
  import { buildFreeformRecordPath } from '../i18n/freeform.js'
45
46
 
@@ -111,9 +112,13 @@ function briefHasContentBody(declaration) {
111
112
 
112
113
  // Build `uuid → { collection, slug }` from the folder document's ref leaves. The
113
114
  // folder is a self-nesting tree under `contents`, nesting via `$children` (the
114
- // site-content invariant — folder.js). A leaf sits in a branch whose
115
- // `path_segment` is the collection; the leaf's `path_segment` is the slug and its
116
- // `entry` is the entity_ref open form `{ model, entity: <uuid> }`. Nested branches
115
+ // site-content invariant — folder.js). A leaf sits in a branch whose `name` is
116
+ // the collection; the leaf's `name` is the slug (the handle) and its `entry` is
117
+ // the entity_ref open form `{ model, entity: <uuid> }`. `path_segment` is not
118
+ // read: the store renamed it on 2026-09-04 and a pull emits the new shape only —
119
+ // a reader that kept the old key would index every record as
120
+ // `{ folderPath: null, slug: undefined }` and rewrite records.yml with
121
+ // `folder: undefined` branches (measured on this reader, 2026-09-04). Nested branches
117
122
  // are walked; the collection is the NEAREST enclosing branch segment (correct for
118
123
  // the default one-branch-per-collection org; a deeply nested virtual org may
119
124
  // differ — see the module header).
@@ -122,11 +127,11 @@ function indexFolder(folderDoc) {
122
127
  const walk = (nodes, folderPath) => {
123
128
  for (const node of nodes || []) {
124
129
  if (node?.kind === 'branch') {
125
- walk(node.$children, node.path_segment ?? folderPath)
130
+ walk(node.$children, node.name ?? folderPath)
126
131
  } else if (node?.kind === 'ref' && node.entry) {
127
132
  // `entry` is `{ model, entity: <uuid> }`; tolerate a bare uuid defensively.
128
133
  const uuid = typeof node.entry === 'object' ? node.entry.entity : node.entry
129
- if (uuid) byUuid.set(uuid, { folderPath, slug: node.path_segment })
134
+ if (uuid) byUuid.set(uuid, { folderPath, slug: node.name })
130
135
  }
131
136
  }
132
137
  }
@@ -381,7 +386,7 @@ export function declarationsToQueriesYml({ document, siteRoot }) {
381
386
  * each record landed with.
382
387
  * @returns {{ status: 'updated'|'unchanged'|'skipped', entries: Array, warnings: string[] }}
383
388
  */
384
- export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
389
+ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale = 'en' }) {
385
390
  const warnings = []
386
391
 
387
392
  const walk = (nodes) => {
@@ -389,10 +394,11 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
389
394
  for (const node of nodes || []) {
390
395
  if (!node || typeof node !== 'object') continue
391
396
  if (node.kind === 'branch') {
392
- const entry = { folder: node.path_segment }
397
+ const entry = { folder: node.name }
393
398
  // Only a BRANCH takes a label. A record carries its own title; the folder
394
- // does not caption its rows.
395
- if (node.name !== undefined) entry.label = node.name
399
+ // does not caption its rows. On the wire the label is a localized map;
400
+ // records.yml carries the source-locale string (a bare string passes).
401
+ if (node.label !== undefined) entry.label = unwrapLocalized(node.label, sourceLocale)
396
402
  entry.records = walk(node.$children)
397
403
  out.push(entry)
398
404
  continue
@@ -404,7 +410,7 @@ export function folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid }) {
404
410
  // means the folder and the pool disagree, and writing the file without it
405
411
  // would quietly unpublish that record on the next push.
406
412
  warnings.push(
407
- `records.yml: a folder leaf ("${node.path_segment ?? '?'}") references a record that ` +
413
+ `records.yml: a folder leaf ("${node.name ?? '?'}") references a record that ` +
408
414
  `was not written locally — the file was left unchanged rather than dropping it.`
409
415
  )
410
416
  return null
@@ -523,7 +529,7 @@ export function recordsToProject({ folderDoc, recordDocs = [], siteRoot, opts =
523
529
  //
524
530
  // The folder ENTITY still carries no `$uuid` we persist: the backend owns the
525
531
  // site's folder, keyed by the site-content uuid.
526
- const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid })
532
+ const records = folderToRecordsYml({ folderDoc, siteRoot, poolPathByUuid, sourceLocale })
527
533
  warnings.push(...records.warnings)
528
534
 
529
535
  // Flush localized record-field translations to locales/records/{locale}.json,