@uniweb/build 0.64.2 → 0.65.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.64.2",
3
+ "version": "0.65.0",
4
4
  "description": "Build tooling for the Uniweb Component Web Platform",
5
5
  "type": "module",
6
6
  "exports": {
@@ -57,15 +57,15 @@
57
57
  "js-yaml": "^4.1.0",
58
58
  "sharp": "^0.35.3",
59
59
  "yaml": "^2.5.0",
60
- "@uniweb/schemas": "^0.7.2",
61
- "@uniweb/content-reader": "^1.2.5",
60
+ "@uniweb/schemas": "^0.8.0",
61
+ "@uniweb/projections": "^0.7.5",
62
+ "@uniweb/theming": "^0.1.15",
62
63
  "@uniweb/content-writer": "^0.3.4",
63
- "@uniweb/projections": "^0.7.4",
64
- "@uniweb/semantic-parser": "^1.4.1",
65
- "@uniweb/theming": "^0.1.15"
64
+ "@uniweb/content-reader": "^1.2.5",
65
+ "@uniweb/semantic-parser": "^1.4.1"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.27.6"
68
+ "@uniweb/runtime": "^0.27.7"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,8 +74,8 @@
74
74
  "@tailwindcss/vite": "^4.0.0",
75
75
  "@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
76
76
  "vite-plugin-svgr": "^4.0.0",
77
- "@uniweb/runtime": "^0.27.6",
78
- "@uniweb/core": "^0.30.1"
77
+ "@uniweb/core": "^0.31.0",
78
+ "@uniweb/runtime": "^0.27.7"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -24,7 +24,7 @@ import { loadFreeformRecord } from './freeform.js'
24
24
  import { resolveQueriesConfig, resolveRecordSchemas, foundationSchemaJson } from '../site/queries-config.js'
25
25
  import { dataKeyTypes, keyOfDefaultRef } from '../uwx/data-key-types.js'
26
26
  import { toDataSchemaDeclaration, isProseMirrorField, isOpenMapSection } from '../uwx/data-schema.js'
27
- import { toDeliveredRecord, contentBodyField, misplacedFields } from '@uniweb/schemas/conform'
27
+ import { toDeliveredRecord, contentBodyField, misplacedFields, mergedFromStored, storedFromMerged } from '@uniweb/schemas/conform'
28
28
  import { resolveDocForLocale } from './merge.js'
29
29
  import { extractUnitsFromDoc } from './extract.js'
30
30
  import { flatFormRefusal, extractExcerpt } from '../site/query-processor.js'
@@ -392,7 +392,7 @@ function isProseMirrorDoc(value) {
392
392
 
393
393
  /**
394
394
  * Every ProseMirror document a record holds, with its path. ⭐ A markdown body is
395
- * delivered in its schema's content body field — `article_body.content` for
395
+ * delivered in its schema's content body field — `body.content` for
396
396
  * `@std/article` — and at `content` only for a record with no schema; a rich field can
397
397
  * sit anywhere else. ⛔ Until 2026-09-24 only `content` at the top was read, which is
398
398
  * where a delivered `@std/article` holds no body.
@@ -796,7 +796,7 @@ export async function extractRecordContent(siteRoot, options = {}) {
796
796
  // record whole beside its lean list, and a deferred field is exactly what the list
797
797
  // does not hold. ⛔ Until 2026-09-14 only the list was read, so a deferred body —
798
798
  // one derived from a schema's brief included — never reached the manifest.
799
- const wholes = await wholeRecords(dataDir, queryName)
799
+ const wholes = await wholeRecords(dataDir, queryName, dataSchemas.get(queryName) ?? null)
800
800
  const extract = (item) => {
801
801
  const whole = wholes.get(recordHandle(item))
802
802
  const skip = derivesExcerpt(item, whole, dataSchemas.get(queryName), excerpts.get(queryName)) ? EXCERPT : null
@@ -847,15 +847,19 @@ async function recordFileNames(dataDir, queryName) {
847
847
  }
848
848
 
849
849
  /**
850
- * A query's per-record files (`recordFileNames`), each record whole, by its handle.
850
+ * A query's per-record files (`recordFileNames`), each record whole, by its handle — as the MERGED
851
+ * view this module walks (`mergedFromStored`): a file holds the record as stored, a key per
852
+ * section (2026-09-27), and translation reads a record with its brief's fields at the top.
851
853
  *
854
+ * @param {Object|null} [dataSchema] - the query's data schema; with none a file is read as it is
852
855
  * @returns {Promise<Map<string, Object>>}
853
856
  */
854
- async function wholeRecords(dataDir, queryName) {
857
+ async function wholeRecords(dataDir, queryName, dataSchema = null) {
855
858
  const out = new Map()
856
859
  for (const name of await recordFileNames(dataDir, queryName)) {
857
860
  try {
858
- const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
861
+ const stored = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
862
+ const record = dataSchema && isPlainObject(stored) ? mergedFromStored(dataSchema, stored) : stored
859
863
  if (isPlainObject(record)) out.set(recordHandle(record), record)
860
864
  } catch (err) {
861
865
  console.warn(`[i18n] Skipping ${queryName}/${name}: ${err.message}`)
@@ -890,7 +894,7 @@ async function pruneRecordFiles(recordsDir, dataDir, keep) {
890
894
 
891
895
  /**
892
896
  * Extract from a ProseMirror document — `field` is where the record holds it
893
- * (`content`, `article_body.content`), which each unit's `field` starts with.
897
+ * (`content`, `body.content`), which each unit's `field` starts with.
894
898
  */
895
899
  function extractFromProseMirrorDoc(doc, context, units, field = 'content') {
896
900
  // The units a page section's body makes (`extract.js::extractUnitsFromDoc`) — the keys translation
@@ -1043,13 +1047,18 @@ export async function buildLocalizedRecords(siteRoot, options = {}) {
1043
1047
  const wholes = new Map() // handle → { source, translated }
1044
1048
  for (const name of recordNames) {
1045
1049
  try {
1046
- const record = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
1050
+ // ⭐ A record's file holds it AS STORED (2026-09-27); it is translated as the merged
1051
+ // view and written back as stored (`mergedFromStored`, `storedFromMerged`).
1052
+ const stored = JSON.parse(await readFile(join(dataDir, queryName, name), 'utf-8'))
1053
+ const { dataSchema } = recordOptions
1054
+ const record = dataSchema && isPlainObject(stored) ? mergedFromStored(dataSchema, stored) : stored
1047
1055
  const translatedRecord = isPlainObject(record)
1048
1056
  ? await translateItemAsync(record, recordDir, translations, schema, recordOptions)
1049
1057
  : record
1050
1058
  if (isPlainObject(record)) wholes.set(recordHandle(record), { source: record, translated: translatedRecord })
1059
+ const written = dataSchema && isPlainObject(translatedRecord) ? storedFromMerged(dataSchema, translatedRecord) : translatedRecord
1051
1060
  await mkdir(localeRecordsDir, { recursive: true })
1052
- await writeFile(join(localeRecordsDir, name), JSON.stringify(translatedRecord, null, 2))
1061
+ await writeFile(join(localeRecordsDir, name), JSON.stringify(written, null, 2))
1053
1062
  } catch (err) {
1054
1063
  console.error(`[i18n] Failed to translate ${queryName}/${name} for ${locale}: ${err.message}`)
1055
1064
  failures.push({ locale, file: `${queryName}/${name}`, message: err.message })
@@ -1265,7 +1274,6 @@ export async function translateRecordData(items, queryName, siteRoot, options =
1265
1274
 
1266
1275
  const one = !!items && typeof items === 'object' && !Array.isArray(items)
1267
1276
  if (!Array.isArray(items) && !one) return items
1268
- const records = one ? [items] : items
1269
1277
 
1270
1278
  const schema = await resolveSchema(queryName, siteRoot)
1271
1279
  const { poolDirs, schemas: dataSchemas, models, targets, excerpts } = await recordQueries(siteRoot)
@@ -1279,11 +1287,15 @@ export async function translateRecordData(items, queryName, siteRoot, options =
1279
1287
  targets,
1280
1288
  excerpt: excerpts.get(queryName),
1281
1289
  }
1290
+ const { dataSchema } = recordOptions
1291
+ // ONE record is a record's own file, which holds it AS STORED (2026-09-27): translated as the
1292
+ // merged view, returned as stored (`mergedFromStored`, `storedFromMerged`).
1293
+ const records = one ? [dataSchema ? mergedFromStored(dataSchema, items) : items] : items
1282
1294
 
1283
1295
  // A lean list's derived excerpt is derived from its record's translated body (`EXCERPT`).
1284
1296
  const wholes = new Map()
1285
1297
  if (!one) {
1286
- for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName)) {
1298
+ for (const [handle, record] of await wholeRecords(join(siteRoot, 'public', DATA_DIR), queryName, dataSchema)) {
1287
1299
  wholes.set(handle, { source: record, translated: await translateItemAsync(record, recordDir, translations, schema, recordOptions) })
1288
1300
  }
1289
1301
  }
@@ -1291,7 +1303,8 @@ export async function translateRecordData(items, queryName, siteRoot, options =
1291
1303
  records.map((item) => translateItemAsync(item, recordDir, translations, schema, { ...recordOptions, whole: wholes.get(recordHandle(item)) }))
1292
1304
  )
1293
1305
 
1294
- return one ? translated[0] : translated
1306
+ if (!one) return translated
1307
+ return dataSchema && isPlainObject(translated[0]) ? storedFromMerged(dataSchema, translated[0]) : translated[0]
1295
1308
  }
1296
1309
 
1297
1310
  // ---------------------------------------------------------------------------
package/src/prerender.js CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  deriveCacheKey,
25
25
  evaluateQuery,
26
26
  } from '@uniweb/core'
27
- import { recordTitle, routePatternToRegex } from '@uniweb/core/route-match'
27
+ import { recordTitle, routePatternToRegex, routeFieldOf } from '@uniweb/core/route-match'
28
28
  import DataStore from '@uniweb/core/datastore'
29
29
  import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
30
30
  import { createFileTransport } from './site/file-transport.js'
@@ -258,6 +258,8 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
258
258
  * @param {Object} [stats] - receives `unrouted[route]`, the records with no param value
259
259
  * @param {Object} [options]
260
260
  * @param {Object|Array|null} [options.siteFetch] - the site's `fetch`, the last level of a route query
261
+ * @param {Object|null} [options.queries] - the site's `config.queries`: a route query that binds the
262
+ * URL's last segment to a field (`where: { slug: ':slug' }`, `routeFieldOf`) is expanded by that field
261
263
  * @returns {Array} Expanded pages array with dynamic pages replaced by concrete instances
262
264
  */
263
265
  /**
@@ -295,7 +297,7 @@ export function localizeRedirectTarget(target, { website, locale, isDefault, rou
295
297
  return (website.basePath || '') + withSlash
296
298
  }
297
299
 
298
- export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null } = {}) {
300
+ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats = { unrouted: {} }, { siteFetch = null, queries = null } = {}) {
299
301
  if (!stats.unrouted) stats.unrouted = {}
300
302
  const expandedPages = []
301
303
 
@@ -378,6 +380,8 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
378
380
 
379
381
  const items = data
380
382
  const key = route.key
383
+ // The field the route query binds the URL's last segment to, if any — else `[slug]` names `$name`.
384
+ const field = routeFieldOf(queries?.[route.config?.query ?? key])
381
385
 
382
386
  onProgress(` Expanding ${page.route} → ${items.length} pages from ${key}`)
383
387
 
@@ -399,7 +403,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
399
403
  // value — the case the rule is for — gets exactly one page. Ruled 2026-09-12
400
404
  // [Diego]. ⛔ This read `item[paramName]` until 2026-09-11 and the whole array
401
405
  // until 2026-09-12, which baked `/tags/a%2Cb`, a URL no lane matches.
402
- const values = routeParamValues(item, paramName)
406
+ const values = routeParamValues(item, paramName, field)
403
407
  if (values.length === 0) {
404
408
  unrouted += 1
405
409
  continue
@@ -470,7 +474,7 @@ export function expandDynamicPages(pages, fetched, onProgress = () => {}, stats
470
474
  if (unrouted > 0) {
471
475
  stats.unrouted[page.route] = unrouted
472
476
  onProgress(
473
- ` ⚠️ ${unrouted} of ${items.length} ${key} records have no "${paramName}" — no page was ` +
477
+ ` ⚠️ ${unrouted} of ${items.length} ${key} records have no "${paramName === 'slug' ? field || '$name' : paramName}" — no page was ` +
474
478
  `generated for them under ${page.route}`
475
479
  )
476
480
  }
@@ -893,6 +897,7 @@ export async function prerenderSite(siteDir, options = {}) {
893
897
  onProgress('Expanding dynamic routes...')
894
898
  siteContent.pages = expandDynamicPages(siteContent.pages, fetched, onProgress, undefined, {
895
899
  siteFetch: siteContent.config?.fetch ?? null,
900
+ queries: siteContent.config?.queries ?? null,
896
901
  })
897
902
 
898
903
  // ⭐ AN EXPANDED PAGE ASKS FOR WHAT ITS ROUTE BINDS — its branch's view, a `deferred:`
@@ -38,6 +38,7 @@
38
38
  import { readFile } from 'node:fs/promises'
39
39
  import { existsSync, readdirSync, statSync } from 'node:fs'
40
40
  import { join, resolve, isAbsolute, extname, basename } from 'node:path'
41
+ import { dataRefOf } from '@uniweb/core/data-keys'
41
42
  import { pathToFileURL } from 'node:url'
42
43
  import { createRequire } from 'node:module'
43
44
  import yaml from 'js-yaml'
@@ -105,8 +106,9 @@ export function collectSchemaRefs(components, foundationData = null) {
105
106
  const readData = (data) => {
106
107
  if (!data || typeof data !== 'object' || data === false) return
107
108
  for (const binding of Object.values(data)) {
108
- const ref = typeof binding === 'string' ? binding : binding?.schema
109
- if (typeof ref === 'string') refs.add(ref)
109
+ // `'@std/article/*'` asks for whole records of `@std/article` — the ref is what precedes it.
110
+ const { ref } = dataRefOf(binding)
111
+ if (ref) refs.add(ref)
110
112
  }
111
113
  }
112
114
  for (const meta of Object.values(components || {})) readData(meta?.data)
@@ -25,7 +25,8 @@
25
25
  */
26
26
 
27
27
  import { isRichSchema } from '@uniweb/core'
28
- import { deliveredFields } from '@uniweb/schemas/conform'
28
+ import { dataRefOf } from '@uniweb/core/data-keys'
29
+ import { briefFieldMap, wholeFieldMap } from '@uniweb/schemas/conform'
29
30
  import { enumValues } from '@uniweb/schemas/format'
30
31
 
31
32
  /**
@@ -189,26 +190,22 @@ function normalizeRichSchemaValue(value) {
189
190
  * @returns {Object|null}
190
191
  */
191
192
  function leanDataSchema(value, dataSchemaMap) {
192
- // Named ref → the resolved schema's fields.
193
- const ref = typeof value === 'string'
194
- ? value
195
- : (value && typeof value.schema === 'string' ? value.schema : null)
193
+ // Named ref → the resolved schema's fields. `'@std/article/*'` asks for whole records of it.
194
+ const { ref, whole } = dataRefOf(value)
196
195
  if (ref) {
197
196
  const resolved = dataSchemaMap[ref]
198
- // ⭐ THE DELIVERED RECORD'S FIELDS (`deliveredFields`) — the brief's at the top and each
199
- // other section as one field under its name — so a default lands where the record
200
- // a component receives carries its field: `@std/article`'s `status` inside
201
- // `article_body`, as the records service delivers it (measured 2026-09-24). A section
202
- // is filled only when the record holds it (`applySchemaToObject` recurses into what is
203
- // there), so a list of briefs gains no section it was not sent.
197
+ // ⭐ THE FIELDS OF WHAT THE COMPONENT RECEIVES (ruled 2026-09-27 [Diego]): a brief's fields at
198
+ // the top (`briefFieldMap`), or, for `'@x/y/*'`, the whole record as stored — one field per
199
+ // section, the brief's included (`wholeFieldMap`) — so a default lands where the record carries
200
+ // its field: `@std/article`'s `status` inside `body`. A section is filled only when the record
201
+ // holds it (`applySchemaToObject` recurses into what is there).
204
202
  //
205
- // ⛔ It was `flatRecordFields` until 2026-09-24 — the retired flat form, every single
206
- // section's fields at the top — so on a delivered record those defaults landed beside
207
- // the section they belong to. And before 2026-09-03 it read `resolved.fields`, which a
208
- // sections-form schema does not have: no defaults at all. `dataSchemaMap` holds each
209
- // schema normalized in its authored form (`fields:` or `sections:`), and only these
210
- // readers say what one record of it looks like.
211
- const fields = deliveredFields(resolved)
203
+ // ⛔ It was the merged `deliveredFields` until 2026-09-27 — the brief's fields beside the other
204
+ // sections' names, one map for two shapes; `flatRecordFields`, the retired flat form, until
205
+ // 2026-09-24; and before 2026-09-03 `resolved.fields`, which a sections-form schema does not
206
+ // have. `dataSchemaMap` holds each schema normalized in its authored form (`fields:` or
207
+ // `sections:`), and only these readers say what one record of it looks like.
208
+ const fields = whole ? wholeFieldMap(resolved) : briefFieldMap(resolved)
212
209
  if (!fields) return null
213
210
  const lean = extractSchemaFields(fields)
214
211
  return Object.keys(lean).length > 0 ? lean : null
@@ -192,8 +192,8 @@ export function schemaForPoolDirs(dirs) {
192
192
  * ⛔ ONE IMPLEMENTATION AND ITS INVERSE, IN ONE PLACE, for the reason this file
193
193
  * exists at all: the reader derives a model from a path and the pull side derives
194
194
  * a path from a model, and if those two ever disagree a pulled record lands
195
- * somewhere the next build reads as a different schema. Same rule as
196
- * `deferredFromSchema` — a deriver and its recognizer must not be two copies.
195
+ * somewhere the next build reads as a different schema. A deriver and its
196
+ * recognizer must not be two copies.
197
197
  *
198
198
  * @param {string} schema - a ref: `@/name` or `@org/name`
199
199
  * @returns {string[]|null} the directory segments below `records/`, or null for
@@ -35,6 +35,7 @@ import { detectFoundationType, parseCatalogRef } from './foundation-ref.js'
35
35
  import { readRegisteredFoundation } from './registered-foundation.js'
36
36
  import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
37
37
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
38
+ import { buildSchema } from '../schema.js'
38
39
  import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
39
40
  import { readFile } from 'node:fs/promises'
40
41
  import yaml from 'js-yaml'
@@ -174,8 +175,6 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
174
175
  }
175
176
  }
176
177
 
177
- await deriveDeferredFromSchemas(siteRoot, siteYml, declarations)
178
-
179
178
  // ⛔ `folderSync` AND `folders` WERE DELETED HERE, 2026-09-06. Both were
180
179
  // `collections.yml` survivors kept "for one step" while `records.yml` took
181
180
  // over, and both had become literals — `true` and `null` — that no longer
@@ -194,7 +193,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
194
193
  }
195
194
 
196
195
  /** What a query over the site's records declares, which an external query cannot. */
197
- const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'deferred', 'excerpt', 'path']
196
+ const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'excerpt', 'path']
198
197
  /** The keys of an external query's `record:` request. */
199
198
  const RECORD_KEYS = ['url', 'method', 'body', 'transform']
200
199
 
@@ -207,7 +206,7 @@ const RECORD_KEYS = ['url', 'method', 'body', 'transform']
207
206
  * `transform` (a dot-path to its records), `where` / `sort` / `limit` evaluated over
208
207
  * them, and `record:` — `{ url, method, body, transform }` — for one record on a
209
208
  * parametric page. What describes the site's records — `schema`, `scope`,
210
- * `deferred`, `excerpt` — is refused beside `url:`. ⛔ `detailUrl:` and `detail:` are
209
+ * `excerpt` — is refused beside `url:`. ⛔ `detailUrl:` and `detail:` are
211
210
  * retired everywhere: their one real case is `record.url`. ⛔ And `limit:` is a whole
212
211
  * number, 0 or more (`refuseLimit`).
213
212
  *
@@ -235,6 +234,17 @@ export function refuseQueryDeclaration(decl) {
235
234
  `on an external query — one with \`url:\`.`
236
235
  )
237
236
  }
237
+ // ⛔ `deferred:` IS RETIRED (2026-09-27 [Diego]: "the `deferred:` concept keeps getting in the way
238
+ // and doesn't match a hosted lane"). A list carries each record's brief, on every site, so a
239
+ // field a list should leave out belongs in a section of its own, outside the brief — where a
240
+ // host leaves it out too. `deferred:` only made a static site's lists differ from a hosted one's.
241
+ if (decl.deferred !== undefined) {
242
+ throw new Error(
243
+ `[uniweb] ${where}: \`deferred:\` is retired. A list carries each record's brief — the fields ` +
244
+ `of its schema's brief section — so a field a list should leave out belongs in a section of ` +
245
+ `its own, outside the brief. Remove the key.`
246
+ )
247
+ }
238
248
  refuseQueryRoute(decl, where)
239
249
  refuseLimit(decl.limit, where)
240
250
  // ⛔ `sync:` IS RETIRED (2026-09-21). `sync: false` kept a query's records off a
@@ -303,6 +313,11 @@ function refuseMethod(method, where) {
303
313
  * downstream. It is stripped here rather than at each consumer, so the payload
304
314
  * has one shape and no consumer has to know the field existed.
305
315
  *
316
+ * ⛔ `name` too: a declaration carries its query's name for the build's own messages, and
317
+ * `config.queries` is keyed by it — repeated inside, it was a second copy nothing read, and it
318
+ * held a key a declaration may want for something else [Diego, 2026-09-27]. Until then every
319
+ * query on the payload read `{ name: 'articles', … }` under `articles`.
320
+ *
306
321
  * Returns undefined for a site with no queries, so `config.queries`
307
322
  * stays absent rather than becoming an empty object — an empty object reads as
308
323
  * "declared, and empty" to anything checking for presence.
@@ -311,90 +326,14 @@ export function toConfigQueries(declarations) {
311
326
  const names = Object.keys(declarations || {})
312
327
  if (names.length === 0) return undefined
313
328
  const out = {}
314
- for (const name of names) {
315
- const { schemaExplicit, ...rest } = declarations[name]
316
- out[name] = rest
329
+ for (const key of names) {
330
+ const { schemaExplicit, name, ...rest } = declarations[key]
331
+ out[key] = rest
317
332
  }
318
333
  return out
319
334
  }
320
335
 
321
336
 
322
- /**
323
- * Fill in `deferred:` from each collection's own data schema.
324
- *
325
- * ⭐ A schema's **brief** section already states what a record's summary is — the
326
- * card, the row, the thing a list shows. Everything else is wanted only when one
327
- * record is the focus. That is exactly what `deferred:` says, so an author with a
328
- * schema should not have to say it twice, in a second vocabulary, with nothing
329
- * checking the two against each other.
330
- *
331
- * ⇒ `deferred` = every top-level section of the schema other than its brief — the keys
332
- * a delivered record holds them under (`article_body` for `@std/article`), which is
333
- * exactly what a host's records service leaves out of a list and adds for `whole`.
334
- * ⛔ It was the flat form's fields minus the brief's until 2026-09-24; a delivered
335
- * record carries those under their section, so stripping them by name stripped nothing.
336
- *
337
- * Derived from the SCHEMA, never from a record. That is what keeps the
338
- * build-derived keys safe without a reserved list: `slug`, `route`, `path`,
339
- * `excerpt`, `image` and `lastModified` are not sections, so they are never
340
- * stripped. A markdown body goes where its content field is — inside `article_body`,
341
- * say — so it is deferred with that section.
342
- *
343
- * ⛔ Silent on every path that cannot answer, because none of them is an error:
344
- *
345
- * - an author-declared `deferred:` wins outright — this never overrides one;
346
- * - no local foundation (a linked or cataloged one), or it is unbuilt → nothing
347
- * to read, and a site must still build;
348
- * - the schema is not in the foundation's built map → the same soft-skip the
349
- * sync lane already applies. `dist/meta/schema.json` carries the schemas
350
- * COMPONENTS reference, so a collection whose schema no component binds is
351
- * simply not there;
352
- * - the schema has no brief, or only one section (`recordLayout`) → there is no
353
- * lean shape to honour, so records stay whole.
354
- *
355
- * The last two are why this reads the built artifact rather than resolving
356
- * schemas itself: it is the same input the sync lane uses, so both lanes agree
357
- * about which schemas exist.
358
- */
359
- async function deriveDeferredFromSchemas(siteRoot, siteYml, declarations) {
360
- const pending = Object.values(declarations).filter(
361
- (d) => d.schema && !Array.isArray(d.deferred)
362
- )
363
- if (pending.length === 0) return
364
-
365
- const dataSchemas = foundationDataSchemas(siteRoot, siteYml)
366
- if (!dataSchemas) return
367
-
368
- for (const decl of pending) {
369
- const heavy = deferredFromSchema(dataSchemas[decl.schema])
370
- if (heavy) decl.deferred = heavy
371
- }
372
- }
373
-
374
- /**
375
- * The `deferred:` a schema implies — every top-level section but its **brief**, by name.
376
- *
377
- * ⛔ ONE IMPLEMENTATION, TWO CALLERS, and that is the point. `deriveDeferredFromSchemas`
378
- * above uses it to FILL an unstated `deferred:`; `uwx/records-project.js` uses it to
379
- * RECOGNIZE a derived value on the way back in, so a pull does not write a derivation
380
- * into the author's file as though they had typed it.
381
- *
382
- * ⚠️ A second copy would drift, and the drift would be invisible: the deriver and the
383
- * inverter would simply stop agreeing about which values are "the derived one", and the
384
- * pull would start persisting values it was written to drop.
385
- *
386
- * @param {object|undefined} schema a data schema, or undefined when it does not resolve
387
- * @returns {string[]|null} the implied deferred list, or null when the schema states no
388
- * brief (a root list, say) or implies nothing heavy — in both cases there is no
389
- * derivation to recognize
390
- */
391
- export function deferredFromSchema(schema) {
392
- const layout = schema ? recordLayout(schema) : null
393
- if (!layout || layout.flat || !layout.brief) return null
394
- const heavy = layout.sections.map(([name]) => name).filter((name) => name !== layout.brief)
395
- return heavy.length ? heavy : null
396
- }
397
-
398
337
  /**
399
338
  * The data schemas a site's records are shaped by, keyed by ref — each resolved from
400
339
  * the LOCAL foundation's SOURCE by the resolver its build uses (`buildDataSchemaMap`),
@@ -446,6 +385,30 @@ function localFoundationSrcDir(siteRoot, siteYml) {
446
385
  }
447
386
  }
448
387
 
388
+ /**
389
+ * A site's foundation's section types, each with its `data:` — what types a query named for a data
390
+ * key (`uwx/data-key-types.js`). Read from the foundation's SOURCE when it is local, as
391
+ * `resolveRecordSchemas` reads its data schemas, so `pnpm dev` without a built foundation types a
392
+ * query as a production build does; else from its built or registered `schema.json`
393
+ * (`foundationSchemaJson`). Null when there is neither.
394
+ *
395
+ * @param {string} siteRoot
396
+ * @param {object} [siteYml] - an already-read site.yml
397
+ * @returns {Promise<object|null>}
398
+ */
399
+ export async function foundationSections(siteRoot, siteYml = null) {
400
+ const yml = siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
401
+ const srcDir = localFoundationSrcDir(siteRoot, yml)
402
+ if (srcDir) {
403
+ try {
404
+ return await buildSchema(srcDir)
405
+ } catch {
406
+ // a foundation whose source does not read is reported by the build that builds it
407
+ }
408
+ }
409
+ return foundationSchemaJson(siteRoot, yml)
410
+ }
411
+
449
412
  /** The data schemas a site's foundation declares, or null when unresolvable. */
450
413
  export function foundationDataSchemas(siteRoot, siteYml) {
451
414
  return foundationSchemaJson(siteRoot, siteYml)?.dataSchemas || null