@uniweb/build 0.79.0 → 0.80.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.79.0",
3
+ "version": "0.80.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/semantic-parser": "^1.5.1",
60
61
  "@uniweb/projections": "^0.7.15",
61
62
  "@uniweb/content-writer": "^0.3.4",
62
- "@uniweb/semantic-parser": "^1.5.1",
63
63
  "@uniweb/schemas": "^0.13.0",
64
64
  "@uniweb/content-reader": "^1.2.8",
65
65
  "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.29.3"
68
+ "@uniweb/runtime": "^0.29.4"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
@@ -74,7 +74,7 @@
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.29.3",
77
+ "@uniweb/runtime": "^0.29.4",
78
78
  "@uniweb/core": "^0.38.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The layout a page renders with when it names none — decided once, by the build, and written
3
+ * into both artifacts: the entry's `capabilities.defaultLayout`, which the runtime reads
4
+ * (`Website.getDefaultLayoutName`), and `_self.defaultLayout` in the schema, which an editor
5
+ * reads to offer a page the areas it will draw. One rule, run once, so the two cannot disagree.
6
+ *
7
+ * ⭐ THE RULE [2026-10-07; Diego: "I think the default layout maybe called `default`"]:
8
+ *
9
+ * 1. `main.js`'s `defaultLayout`, written as that layout's own name — `DocsLayout` for
10
+ * `docs` — so a reader finds it in `_layouts` by key. ⛔ One that names none of the
11
+ * foundation's layouts stops the build: at render it fell to the built-in layout with
12
+ * nothing saying why.
13
+ * 2. Else the layout whose name is `default`, by the one naming rule (`layoutNameKey`):
14
+ * `Default`, `DefaultLayout`, `default-layout`.
15
+ * 3. Else none — the runtime's built-in layout, which draws `header`, the body and `footer`.
16
+ *
17
+ * ⛔ NOT "the only layout". Under that rule, adding a second layout would change the layout of
18
+ * every page that names none — a non-local effect nobody asked for. A foundation that means its
19
+ * one layout as the default says so, as every template does.
20
+ *
21
+ * ⛔ *Until 2026-10-07 the build copied the declared value and derived nothing, so a layout named
22
+ * `Default` was the default only when `main.js` said so — and a reader of the schema finding none
23
+ * had to guess what the runtime would draw.*
24
+ *
25
+ * @module @uniweb/build/foundation/default-layout
26
+ */
27
+
28
+ import { findLayoutEntry } from '@uniweb/core/layout-name'
29
+
30
+ /**
31
+ * @param {*} declared - `main.js`'s `defaultLayout`, as `loadFoundationConfig` reads it
32
+ * @param {string[]} layoutNames - the foundation's layouts (`discoverLayoutsInPath`)
33
+ * @returns {string|null} the layout's own name, or null for the built-in layout
34
+ * @throws {Error} when `declared` names none of the layouts
35
+ */
36
+ export function resolveDefaultLayout(declared, layoutNames = []) {
37
+ const byName = Object.fromEntries((layoutNames || []).map((name) => [name, name]))
38
+
39
+ if (declared !== undefined && declared !== null && declared !== '') {
40
+ const name = typeof declared === 'string' ? findLayoutEntry(byName, declared) : undefined
41
+ if (name) return name
42
+ const known = Object.keys(byName)
43
+ throw new Error(
44
+ `main.js: \`defaultLayout\` names ${JSON.stringify(declared)}, which is not one of this foundation's layouts` +
45
+ (known.length > 0 ? ` (${known.join(', ')})` : ' — it has none in src/layouts/') +
46
+ '. Name one of them, or remove the key: a page that names no layout then renders the built-in ' +
47
+ 'layout, or one named `Default`.'
48
+ )
49
+ }
50
+
51
+ return findLayoutEntry(byName, 'default') ?? null
52
+ }
@@ -32,6 +32,7 @@ import {
32
32
  LAYOUTS_PATH
33
33
  } from './schema.js'
34
34
  import { extractAllRuntimeSchemas, extractAllLayoutRuntimeSchemas, extractFoundationRuntime } from './runtime-schema.js'
35
+ import { resolveDefaultLayout } from './foundation/default-layout.js'
35
36
  import { SCHEMA_EXTENSIONS } from './resolve-data-schema.js'
36
37
 
37
38
  /**
@@ -124,6 +125,7 @@ function generateEntrySource(components, options = {}) {
124
125
  cssPath = null,
125
126
  foundationExports = null,
126
127
  foundationRuntime = { code: [], data: {} },
128
+ defaultLayout = null,
127
129
  meta = {},
128
130
  layouts = {},
129
131
  layoutMeta = {},
@@ -188,6 +190,9 @@ function generateEntrySource(components, options = {}) {
188
190
  for (const key of foundationRuntime.code) capParts.push(`${key}: _foundationModule.default?.${key}`)
189
191
  for (const [key, value] of Object.entries(foundationRuntime.data)) capParts.push(`${key}: ${JSON.stringify(value)}`)
190
192
  }
193
+ // The resolved default, with or without a main.js — a layout named `Default` is one
194
+ // (`resolveDefaultLayout`), and `_self.defaultLayout` in the schema carries the same name.
195
+ if (defaultLayout) capParts.push(`defaultLayout: ${JSON.stringify(defaultLayout)}`)
191
196
  if (layoutNames.length > 0) {
192
197
  capParts.push(`layouts: { ${layoutNames.join(', ')} }`)
193
198
  }
@@ -310,6 +315,11 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
310
315
  const foundationExports = detectFoundationExports(srcDir)
311
316
  const foundationConfig = await loadFoundationConfig(srcDir)
312
317
  const foundationRuntime = foundationExports ? extractFoundationRuntime(foundationConfig) : { code: [], data: {} }
318
+ // Which layout a page that names none renders with — the build's answer, written here and in
319
+ // the schema's `_self.defaultLayout` (`buildSchema`) from one rule, so the runtime and an editor
320
+ // agree. It replaces the declared value `extractFoundationRuntime` copied.
321
+ const defaultLayout = resolveDefaultLayout(foundationConfig.defaultLayout, layoutNames)
322
+ delete foundationRuntime.data.defaultLayout
313
323
 
314
324
  // Per-component runtime metadata. ⭐ No data schema is resolved for it: the runtime reads a
315
325
  // declared key's ref and nothing of its shape (2026-10-05), so the entry no longer depends on
@@ -329,6 +339,7 @@ export async function generateEntryPoint(srcDir, outputPath = null, options = {}
329
339
  cssPath,
330
340
  foundationExports,
331
341
  foundationRuntime,
342
+ defaultLayout,
332
343
  meta,
333
344
  layouts,
334
345
  layoutMeta,
package/src/schema.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  unnameableIn,
29
29
  } from './foundation/derive-supports.js'
30
30
  import { RENAMED_SERVICES } from './uwx/services-request.js'
31
+ import { resolveDefaultLayout } from './foundation/default-layout.js'
31
32
  import { foundationNameOf, splitFoundationName } from './foundation-name.js'
32
33
  import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
33
34
 
@@ -1236,6 +1237,9 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1236
1237
 
1237
1238
  // Discover layouts from src/layouts/
1238
1239
  const layouts = await discoverLayoutsInPath(srcDir)
1240
+ // The layout a page that names none renders with — the entry gets the same answer
1241
+ // (`generate-entry.js`), so an editor reading `_self.defaultLayout` offers what the runtime draws.
1242
+ const defaultLayout = resolveDefaultLayout(foundationConfig.defaultLayout, Object.keys(layouts))
1239
1243
  await refuseFlagsOnListSchemas(
1240
1244
  [
1241
1245
  ...Object.entries(components).map(([name, entry]) => [`${name} (meta.js)`, entry?.data]),
@@ -1260,8 +1264,9 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1260
1264
  }
1261
1265
  }
1262
1266
 
1263
- // Build _self, stripping the raw extension boolean in favor of normalized role
1264
- const { extension: _ext, ...configWithoutExtension } = foundationConfig
1267
+ // Build _self, stripping the raw extension boolean in favor of normalized role, and the
1268
+ // declared `defaultLayout` in favor of the resolved one (`resolveDefaultLayout`)
1269
+ const { extension: _ext, defaultLayout: _declaredLayout, ...configWithoutExtension } = foundationConfig
1265
1270
 
1266
1271
  // `supports` is the one identity field the build knows better than the file.
1267
1272
  // `identity.supports` is already normalized (absent stays absent, `[]` stays
@@ -1303,7 +1308,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1303
1308
  ...supports,
1304
1309
  ...(name && { name }),
1305
1310
  ...(foundationConfig.description && { description: foundationConfig.description }),
1306
- ...(foundationConfig.defaultLayout && { defaultLayout: foundationConfig.defaultLayout }),
1311
+ ...(defaultLayout && { defaultLayout }),
1307
1312
  ...(isExtension && { role: 'extension' }),
1308
1313
  // The build's fact, last: which form the entries below hold.
1309
1314
  schemaFormat: SCHEMA_FORMAT,
@@ -22,7 +22,7 @@
22
22
  */
23
23
 
24
24
  import { readFile, readdir, stat } from 'node:fs/promises'
25
- import { resolveQueriesConfig, toConfigQueries } from './queries-config.js'
25
+ import { resolveQueriesConfig, toConfigQueries, dataKeyTyping } from './queries-config.js'
26
26
  import { isSectionKey } from '@uniweb/schemas/section'
27
27
  import { parseNumericPrefix, compareByNumericPrefix } from '../utils/numeric-prefix.js'
28
28
  import { isMarkdownFile, isIgnoredFolder } from '../utils/content-files.js'
@@ -2511,13 +2511,17 @@ export async function collectSiteContent(sitePath, options = {}) {
2511
2511
  // merged both, so a declaration in the second file was invisible here: never
2512
2512
  // compiled, `data: <name>` delivering nothing, while sync pushed it fine.
2513
2513
  //
2514
- // ⭐ `config.queries` — framework's own payload key, framework's own readers.
2515
- // The backend's projector never emits it (their measurement: 17 `config` keys,
2516
- // not this one), and hosting renders with framework's code. There was nobody to
2517
- // coordinate with, which is exactly why it had no excuse to stay wrong.
2518
- const byQuery = toConfigQueries(
2519
- (await resolveQueriesConfig(sitePath, { siteYml: siteConfig })).declarations
2520
- )
2514
+ // ⭐ `config.queries` — what a page's queries are, which a section's keys are filled by. A
2515
+ // backend-served payload carries its own projection of each (`schema`, `where`, `sort`,
2516
+ // `limit`, `scope`) since 2026-09-05. ⛔ This said the backend's projector never emitted it,
2517
+ // which was true only before then.
2518
+ //
2519
+ // ⭐ A query a data key types carries the type, as a push names it (`dataKeyTyping`), so a key
2520
+ // typed `@/post` fills from `articles:` on a static site as it does on a hosted one.
2521
+ const { declarations } = await resolveQueriesConfig(sitePath, { siteYml: siteConfig })
2522
+ const nameDefaulted = Object.values(declarations).some((d) => d?.schemaExplicit === false)
2523
+ const typing = nameDefaulted ? await dataKeyTyping(sitePath, declarations, { siteYml: siteConfig }) : null
2524
+ const byQuery = toConfigQueries(declarations, typing)
2521
2525
  if (byQuery) siteConfig.queries = byQuery
2522
2526
 
2523
2527
  // Record the RESOLVED base (--base > UNIWEB_BASE > site.yml::base) on the
@@ -41,6 +41,7 @@ import { refuseQueryRoute, refuseLimit, refuseFilter } from './data-fetcher.js'
41
41
  import { readFile } from 'node:fs/promises'
42
42
  import yaml from 'js-yaml'
43
43
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
44
+ import { dataKeyTypes } from '../uwx/data-key-types.js'
44
45
 
45
46
  // Read its own YAML rather than importing the site build's helper. That import
46
47
  // pointed the wrong way — a config resolver reaching into the collector that
@@ -320,21 +321,73 @@ function refuseMethod(method, where) {
320
321
  * held a key a declaration may want for something else [Diego, 2026-09-27]. Until then every
321
322
  * query on the payload read `{ name: 'articles', … }` under `articles`.
322
323
  *
324
+ * ⭐ A query that declares no `schema:` and whose records a data key types carries the TYPE —
325
+ * `articles` → `@/post` (`dataKeyTyping`) — which is what a push names it on the wire
326
+ * (`uwx/site.js::queriesNested`). The schema a page's query carries is what a section's key is
327
+ * filled by at render (automatic `as`), so the two lanes fill the same keys. ⛔ Until 2026-10-08 the
328
+ * static payload carried the name (`@/articles`), and a key typed `@/post` filled on a hosted site
329
+ * and stayed empty on a static one.
330
+ *
323
331
  * Returns undefined for a site with no queries, so `config.queries`
324
332
  * stays absent rather than becoming an empty object — an empty object reads as
325
333
  * "declared, and empty" to anything checking for presence.
334
+ *
335
+ * @param {object} declarations - `resolveQueriesConfig(…).declarations`
336
+ * @param {Map<string, string>|null} [typing] - `dataKeyTyping`: name-defaulted ref → its type
326
337
  */
327
- export function toConfigQueries(declarations) {
338
+ export function toConfigQueries(declarations, typing = null) {
328
339
  const names = Object.keys(declarations || {})
329
340
  if (names.length === 0) return undefined
330
341
  const out = {}
331
342
  for (const key of names) {
332
343
  const { schemaExplicit, name, ...rest } = declarations[key]
333
- out[key] = rest
344
+ const typed = schemaExplicit === false ? typing?.get(rest.schema) : null
345
+ out[key] = typed ? { ...rest, schema: typed } : rest
334
346
  }
335
347
  return out
336
348
  }
337
349
 
350
+ /**
351
+ * ⭐ THE TYPE A DATA KEY GIVES A NAME — `@/articles` → `@/post` (ruled 2026-09-25 [Diego];
352
+ * `uwx/data-key-types.js`), decided for the static build as a push decides it
353
+ * (`uwx/records.js::typedFor`), so both lanes give a query one schema.
354
+ *
355
+ * A name-defaulted ref, `@/<name>`, stands for the type of the data key of that name when:
356
+ *
357
+ * - no data schema has the name — `@/<name>` resolves to nothing;
358
+ * - the foundation's section types declare the key `<name>` with one type, and it resolves;
359
+ * - no query asks for `@/<name>` explicitly — that one is the author's, and fails loudly.
360
+ *
361
+ * Then `records/<name>/` holds records of the type, and a query named `<name>` that declares no
362
+ * schema is a query of it. ⚠️ Where the records live on disk and how their translations are keyed
363
+ * stay keyed by the name.
364
+ *
365
+ * @param {string} siteRoot
366
+ * @param {object} declarations - `resolveQueriesConfig(…).declarations`, for which refs are explicit
367
+ * @param {object} [opts]
368
+ * @param {object} [opts.siteYml] - an already-read site.yml
369
+ * @returns {Promise<Map<string, string>>} name-defaulted ref → the type; empty when none
370
+ */
371
+ export async function dataKeyTyping(siteRoot, declarations, opts = {}) {
372
+ const typing = new Map()
373
+ const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
374
+ const keyTypes = dataKeyTypes(await foundationSections(siteRoot, siteYml))
375
+ if (keyTypes.size === 0) return typing
376
+ const explicit = new Set(
377
+ Object.values(declarations || {})
378
+ .filter((d) => d && d.schemaExplicit === true)
379
+ .map((d) => d.schema)
380
+ )
381
+ const named = [...keyTypes.keys()].map(defaultSchema)
382
+ const { schemas } = await resolveRecordSchemas(siteRoot, [...named, ...keyTypes.values()], { siteYml })
383
+ for (const [key, type] of keyTypes) {
384
+ const ref = defaultSchema(key)
385
+ if (schemas[ref] || explicit.has(ref) || !schemas[type]) continue
386
+ typing.set(ref, type)
387
+ }
388
+ return typing
389
+ }
390
+
338
391
 
339
392
  /**
340
393
  * The data schemas a site's records are shaped by, keyed by ref — each resolved from
@@ -65,8 +65,8 @@ import { readEntityPool, groupPoolBySchema, poolDirsForSchema } from './entity-p
65
65
  import { readRecordsConfig, resolveFolder, folderTreeOrder } from './records-config.js'
66
66
  import { isDraftRecord } from './record-draft.js'
67
67
  import { refuseNotOneRecord } from './record-file.js'
68
- import { resolveRecordSchemas, resolveQueriesConfig, foundationSections } from './queries-config.js'
69
- import { dataKeyTypes } from '../uwx/data-key-types.js'
68
+ import { resolveRecordSchemas, resolveQueriesConfig, dataKeyTyping } from './queries-config.js'
69
+ import { keyOfDefaultRef } from '../uwx/data-key-types.js'
70
70
  import { parseFrontmatter } from '../utils/frontmatter.js'
71
71
  import { toDeliveredRecord, toStoredRecord, storedPath, briefFieldMap, contentBodyField, misplacedFields, mapReferences, recordLayout } from '@uniweb/schemas/conform'
72
72
  import { collectNestedRefs } from '@uniweb/schemas/format'
@@ -1197,10 +1197,24 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1197
1197
  const poolBySchema = groupPoolBySchema(pool.entities)
1198
1198
  const recordsRoot = resolve(siteDir, pool.dir)
1199
1199
 
1200
+ // ⭐ A FOLDER NAMED FOR A DATA KEY HOLDS RECORDS OF THE KEY'S TYPE (ruled 2026-09-25 [Diego];
1201
+ // `dataKeyTyping`): `records/articles/`, when no data schema has the name and the foundation types
1202
+ // `articles` as `@/post`, holds `@/post` records — so they join that type's records, as a push
1203
+ // sends them, and any query of `@/post` reads them, as a host answers one. ⛔ Until 2026-10-08 they
1204
+ // stayed under `@/articles`: only a query named `articles` read them, and it carried `@/articles`
1205
+ // on the payload where a push named `@/post`.
1206
+ const typing = await recordTyping(siteDir, queriesConfig, poolBySchema)
1207
+ for (const [ref, type] of typing) {
1208
+ const held = poolBySchema.get(ref)
1209
+ if (!held) continue
1210
+ poolBySchema.delete(ref)
1211
+ poolBySchema.set(type, [...(poolBySchema.get(type) || []), ...held])
1212
+ }
1213
+
1200
1214
  // ⭐ THE DATA SCHEMA EACH QUERY'S RECORDS ARE DELIVERED IN (`deliverRecord`) — resolved
1201
1215
  // from the foundation's source once for every query. A query with none compiles its
1202
1216
  // records as their files hold them.
1203
- const dataSchemas = options.dataSchemas ?? (await querySchemas(siteDir, queriesConfig))
1217
+ const dataSchemas = options.dataSchemas ?? (await querySchemas(siteDir, queriesConfig, typing))
1204
1218
  const references = referenceHydrator({ poolBySchema, dataSchemas, siteDir, recordsRoot, basePath, includeDrafts })
1205
1219
 
1206
1220
  const results = {}
@@ -1211,6 +1225,9 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1211
1225
  // be an empty `/data/<name>.json` standing in for a live source.
1212
1226
  if (config && typeof config === 'object' && config.url !== undefined) continue
1213
1227
  const parsed = parseQueryConfig(name, config)
1228
+ // A query that still names its folder (`@/articles`) is a query of the folder's type.
1229
+ const folderRef = parsed.schema
1230
+ if (parsed.schema && typing.has(parsed.schema)) parsed.schema = typing.get(parsed.schema)
1214
1231
  parsed.poolEntities = parsed.schema ? poolBySchema.get(parsed.schema) || [] : []
1215
1232
  parsed.placements = folder.placements
1216
1233
  parsed.folderRank = folderRank
@@ -1218,7 +1235,7 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1218
1235
  parsed.dataSchema = (parsed.schema && dataSchemas[parsed.schema]) || null
1219
1236
  parsed.references = references
1220
1237
  if (parsed.poolEntities.length === 0) {
1221
- const dirs = parsed.schema ? poolDirsForSchema(parsed.schema) : null
1238
+ const dirs = folderRef ? poolDirsForSchema(folderRef) : null
1222
1239
  console.warn(
1223
1240
  `[query-processor] Query "${name}" matches no records — ` +
1224
1241
  (dirs
@@ -1240,12 +1257,12 @@ export async function processQueries(siteDir, queriesConfig, recordsDir, basePat
1240
1257
  * its records are compiled as their files hold them, which is not the shape a
1241
1258
  * component receives from a host.
1242
1259
  */
1243
- async function querySchemas(siteDir, queriesConfig) {
1244
- const refs = []
1260
+ async function querySchemas(siteDir, queriesConfig, typing = new Map()) {
1261
+ const refs = [...typing.values()]
1245
1262
  for (const config of Object.values(queriesConfig)) {
1246
1263
  if (config && typeof config === 'object' && config.url !== undefined) continue
1247
1264
  const ref = typeof config === 'string' ? config : config?.schema
1248
- if (typeof ref === 'string') refs.push(ref)
1265
+ if (typeof ref === 'string' && !typing.has(ref)) refs.push(ref)
1249
1266
  }
1250
1267
  const { schemas, failures } = await resolveRecordSchemas(siteDir, refs)
1251
1268
  for (const { ref, message } of failures) {
@@ -1254,28 +1271,25 @@ async function querySchemas(siteDir, queriesConfig) {
1254
1271
  `their files hold them rather than as a component receives them: ${message}`
1255
1272
  )
1256
1273
  }
1257
- await typeByDataKey(siteDir, queriesConfig, schemas)
1258
1274
  return schemas
1259
1275
  }
1260
1276
 
1261
1277
  /**
1262
- * ⭐ A QUERY NAMED FOR A DATA KEY HOLDS RECORDS OF THE KEY'S TYPE (ruled 2026-09-25 [Diego],
1263
- * `uwx/data-key-types.js`). `articles:` with no `schema:` names `@/articles`; when no data schema
1264
- * has that name while the foundation's section types declare `data: { articles: '@/post' }`, its
1265
- * records are `@/post` records — a push sends them as such, and they are delivered as such here,
1266
- * where they still live and are keyed by the name (`records/articles/`). Never for a schema the
1267
- * query asked for explicitly: that one is the author's, and does not resolve. ⛔ Until 2026-09-27
1268
- * this lane compiled them as if they had no data schema, while a push typed them.
1278
+ * ⭐ WHICH FOLDERS HOLD RECORDS OF A DATA KEY'S TYPE (ruled 2026-09-25 [Diego]): `@/articles` →
1279
+ * `@/post` — `dataKeyTyping`, the rule a push applies, asked only when the site has a folder or a
1280
+ * query it could apply to: a name-defaulted ref, `@/<name>`. ⛔ Until 2026-10-08 this lane typed a
1281
+ * query's RECORDS by aliasing its `@/<name>` to the type's schema, while its query and its folder
1282
+ * kept the name.
1269
1283
  *
1270
1284
  * @param {string} siteDir
1271
- * @param {Object} queriesConfig
1272
- * @param {Object} schemas - resolved data schemas by ref; a typed query's ref is added to it
1285
+ * @param {Object} queriesConfig - what the build compiles (`config.queries`)
1286
+ * @param {Map<string, Array>} poolBySchema - the records, by the ref their folder names
1287
+ * @returns {Promise<Map<string, string>>} name-defaulted ref → the type
1273
1288
  */
1274
- async function typeByDataKey(siteDir, queriesConfig, schemas) {
1275
- const untyped = Object.entries(queriesConfig).filter(
1276
- ([name, c]) => c && typeof c === 'object' && c.url === undefined && c.schema === `@/${name}` && !schemas[c.schema]
1277
- )
1278
- if (untyped.length === 0) return
1289
+ async function recordTyping(siteDir, queriesConfig, poolBySchema) {
1290
+ const candidate = (ref) => typeof ref === 'string' && keyOfDefaultRef(ref) !== null
1291
+ const queried = Object.values(queriesConfig).map((c) => (typeof c === 'string' ? c : c?.schema))
1292
+ if (![...poolBySchema.keys(), ...queried].some(candidate)) return new Map()
1279
1293
  // `config.queries` carries no trace of whether its author wrote the schema; the declarations do.
1280
1294
  let declarations = {}
1281
1295
  try {
@@ -1283,17 +1297,11 @@ async function typeByDataKey(siteDir, queriesConfig, schemas) {
1283
1297
  } catch {
1284
1298
  // a declaration the build refuses is reported where the build reads it
1285
1299
  }
1286
- const pending = untyped.filter(([name]) => declarations[name]?.schemaExplicit !== true)
1287
- if (pending.length === 0) return
1288
- const keyTypes = dataKeyTypes(await foundationSections(siteDir))
1289
- const wanted = new Map()
1290
- for (const [name, c] of pending) {
1291
- const type = keyTypes.get(name)
1292
- if (type) wanted.set(c.schema, type)
1300
+ try {
1301
+ return await dataKeyTyping(siteDir, declarations)
1302
+ } catch {
1303
+ return new Map() // a foundation that does not read is reported by the build that reads it
1293
1304
  }
1294
- if (wanted.size === 0) return
1295
- const { schemas: types } = await resolveRecordSchemas(siteDir, wanted.values())
1296
- for (const [ref, type] of wanted) if (types[type]) schemas[ref] = types[type]
1297
1305
  }
1298
1306
 
1299
1307
  /**
@@ -7,8 +7,10 @@
7
7
  // records folder (`records/team/`). When no data schema has that name, but the foundation's
8
8
  // section types declare the data key of that name with a type — `data: { team: '@/member' }` in a
9
9
  // `meta.js` — the records ARE of that type: a push sends them as its entities, and the query names
10
- // it on the wire. ⛔ Until 2026-09-25 they were read as having no data schema at all and went out
11
- // as static files, although the one section that reads them declared what they are.
10
+ // it on the wire; the static build names it on the payload and reads them as the type's records
11
+ // (`site/queries-config.js::dataKeyTyping`, since 2026-10-08 — until then the payload named the query
12
+ // `@/team`). ⛔ Until 2026-09-25 they were read as having no data schema at all and went out as
13
+ // static files, although the one section that reads them declared what they are.
12
14
  //
13
15
  // ⚠️ THIS DECIDES ONLY WHICH DATA SCHEMA THE RECORDS BELONG TO. Where they live on disk, how the
14
16
  // site builds them and how their translations are keyed stay keyed by the name (`team`), as the
package/src/uwx/index.js CHANGED
@@ -88,6 +88,9 @@ export {
88
88
  sectionRecordToFile,
89
89
  pageSectionsToFiles,
90
90
  siteContentDocumentToProject,
91
+ // The file the author keeps a section in (`1-hero.md` holds `hero`) — what a section
92
+ // unit's path names on disk, for a push recording what it delivered.
93
+ existingSectionFile,
91
94
  } from './site-project.js'
92
95
  export {
93
96
  createTranslationCollector,
@@ -96,8 +96,12 @@ export function collectSiteUnits(doc, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.
96
96
  export function walkSiteUnits(doc, cb, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale) {
97
97
  const walkSections = (sections, dir) => {
98
98
  for (const record of sections || []) {
99
- // The projector's own file rule, so a unit's path here is the file it is
100
- // written to — including an app-created section named from its `$uuid`.
99
+ // The projector's own file rule — including an app-created section named from its
100
+ // `$uuid`. ⚠️ The path NAMES the unit by its id; it is not always the author's file:
101
+ // a pull writes into an existing `1-about.md` or `@about.md` in place
102
+ // (`existingSectionFile`), so a reader mapping a unit to disk looks it up there.
103
+ // (This said "a unit's path here is the file it is written to" until 2026-10-07,
104
+ // and a push recorded no file for a numbered section.)
101
105
  const base = sectionFileBase(record)
102
106
  if (base) cb(`${dir}/${base}.md`, record, 'section')
103
107
  walkSections(record.$children, dir)
@@ -587,7 +587,7 @@ function listedNames(list) {
587
587
  //
588
588
  // ⛔ A top-level section never takes an `@` file: that is a child's name, and the build renders an
589
589
  // `@` file no `nest:` claims only as an orphan appended at the end — listed by name, it is not rendered.
590
- function existingSectionFile(pageDir, fileBase, stableId, { child = false } = {}) {
590
+ export function existingSectionFile(pageDir, fileBase, stableId, { child = false } = {}) {
591
591
  if (!existsSync(pageDir)) return null
592
592
  const entries = readdirSync(pageDir).filter((e) => isMarkdownFile(e) && (child || !e.startsWith('@')))
593
593
  const found =