@uniweb/build 0.79.0 → 0.80.1

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.1",
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/projections": "^0.7.15",
61
- "@uniweb/content-writer": "^0.3.4",
60
+ "@uniweb/content-reader": "^1.2.8",
62
61
  "@uniweb/semantic-parser": "^1.5.1",
62
+ "@uniweb/projections": "^0.7.15",
63
63
  "@uniweb/schemas": "^0.13.0",
64
- "@uniweb/content-reader": "^1.2.8",
64
+ "@uniweb/content-writer": "^0.3.4",
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,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.29.3",
78
- "@uniweb/core": "^0.38.0"
77
+ "@uniweb/core": "^0.38.0",
78
+ "@uniweb/runtime": "^0.29.4"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -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
@@ -77,8 +78,10 @@ export const QUERIES_YML_RELPATH = 'queries.yml'
77
78
  // Identity has no boundary to get wrong, and an author wanting a different schema
78
79
  // name writes `schema:`, which is one line and says what it means.
79
80
  //
80
- // Exported so the inverse (projection) can drop a `schema:` that merely restates
81
- // this default, keeping a projected queries.yml as terse as the author left it.
81
+ // ⛔ A pull does not drop a `schema:` equal to this default: it writes the schema the site holds,
82
+ // in its one spelling (`uwx/records-project.js::declToFileShape`). Until 2026-10-08 it did, keeping
83
+ // a projected queries.yml as terse as the author left it — and a clone, with no author's file to
84
+ // look at, wrote `series: { schema: '@/series' }` back as `series: {}`.
82
85
  export function defaultSchema(name) {
83
86
  return `@/${name}`
84
87
  }
@@ -320,21 +323,73 @@ function refuseMethod(method, where) {
320
323
  * held a key a declaration may want for something else [Diego, 2026-09-27]. Until then every
321
324
  * query on the payload read `{ name: 'articles', … }` under `articles`.
322
325
  *
326
+ * ⭐ A query that declares no `schema:` and whose records a data key types carries the TYPE —
327
+ * `articles` → `@/post` (`dataKeyTyping`) — which is what a push names it on the wire
328
+ * (`uwx/site.js::queriesNested`). The schema a page's query carries is what a section's key is
329
+ * filled by at render (automatic `as`), so the two lanes fill the same keys. ⛔ Until 2026-10-08 the
330
+ * static payload carried the name (`@/articles`), and a key typed `@/post` filled on a hosted site
331
+ * and stayed empty on a static one.
332
+ *
323
333
  * Returns undefined for a site with no queries, so `config.queries`
324
334
  * stays absent rather than becoming an empty object — an empty object reads as
325
335
  * "declared, and empty" to anything checking for presence.
336
+ *
337
+ * @param {object} declarations - `resolveQueriesConfig(…).declarations`
338
+ * @param {Map<string, string>|null} [typing] - `dataKeyTyping`: name-defaulted ref → its type
326
339
  */
327
- export function toConfigQueries(declarations) {
340
+ export function toConfigQueries(declarations, typing = null) {
328
341
  const names = Object.keys(declarations || {})
329
342
  if (names.length === 0) return undefined
330
343
  const out = {}
331
344
  for (const key of names) {
332
345
  const { schemaExplicit, name, ...rest } = declarations[key]
333
- out[key] = rest
346
+ const typed = schemaExplicit === false ? typing?.get(rest.schema) : null
347
+ out[key] = typed ? { ...rest, schema: typed } : rest
334
348
  }
335
349
  return out
336
350
  }
337
351
 
352
+ /**
353
+ * ⭐ THE TYPE A DATA KEY GIVES A NAME — `@/articles` → `@/post` (ruled 2026-09-25 [Diego];
354
+ * `uwx/data-key-types.js`), decided for the static build as a push decides it
355
+ * (`uwx/records.js::typedFor`), so both lanes give a query one schema.
356
+ *
357
+ * A name-defaulted ref, `@/<name>`, stands for the type of the data key of that name when:
358
+ *
359
+ * - no data schema has the name — `@/<name>` resolves to nothing;
360
+ * - the foundation's section types declare the key `<name>` with one type, and it resolves;
361
+ * - no query asks for `@/<name>` explicitly — that one is the author's, and fails loudly.
362
+ *
363
+ * Then `records/<name>/` holds records of the type, and a query named `<name>` that declares no
364
+ * schema is a query of it. ⚠️ Where the records live on disk and how their translations are keyed
365
+ * stay keyed by the name.
366
+ *
367
+ * @param {string} siteRoot
368
+ * @param {object} declarations - `resolveQueriesConfig(…).declarations`, for which refs are explicit
369
+ * @param {object} [opts]
370
+ * @param {object} [opts.siteYml] - an already-read site.yml
371
+ * @returns {Promise<Map<string, string>>} name-defaulted ref → the type; empty when none
372
+ */
373
+ export async function dataKeyTyping(siteRoot, declarations, opts = {}) {
374
+ const typing = new Map()
375
+ const siteYml = opts.siteYml || (await readYamlFile(join(siteRoot, 'site.yml')))
376
+ const keyTypes = dataKeyTypes(await foundationSections(siteRoot, siteYml))
377
+ if (keyTypes.size === 0) return typing
378
+ const explicit = new Set(
379
+ Object.values(declarations || {})
380
+ .filter((d) => d && d.schemaExplicit === true)
381
+ .map((d) => d.schema)
382
+ )
383
+ const named = [...keyTypes.keys()].map(defaultSchema)
384
+ const { schemas } = await resolveRecordSchemas(siteRoot, [...named, ...keyTypes.values()], { siteYml })
385
+ for (const [key, type] of keyTypes) {
386
+ const ref = defaultSchema(key)
387
+ if (schemas[ref] || explicit.has(ref) || !schemas[type]) continue
388
+ typing.set(ref, type)
389
+ }
390
+ return typing
391
+ }
392
+
338
393
 
339
394
  /**
340
395
  * 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,
@@ -4,7 +4,6 @@
4
4
  export {
5
5
  resolveQueriesConfig,
6
6
  queriesYmlPath,
7
- defaultSchema,
8
7
  foundationDataSchemas,
9
8
  foundationSchemaJson,
10
9
  QUERIES_YML_RELPATH,
@@ -41,7 +41,7 @@ import yaml from 'js-yaml'
41
41
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
42
42
  import { parseFrontmatter } from './entity-source.js'
43
43
  import { writeRecordFile, writeQueriesConfig, writeRecordsConfig } from './project-writer.js'
44
- import { defaultSchema, foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
44
+ import { foundationDataSchemas, foundationSchemaJson, QUERIES_YML_RELPATH } from './queries-config.js'
45
45
  import { dataKeyTypes } from './data-key-types.js'
46
46
  import { poolDirsForSchema, schemaForPoolDirs, resolveRecordsDir } from '../site/entity-pool.js'
47
47
  import { folderYmlPath } from '../site/records-config.js'
@@ -277,8 +277,8 @@ function setIf(obj, key, value) {
277
277
  // - `path:` is written VERBATIM, and omitted entirely when it equals the default
278
278
  // (the query's own name under the pool).
279
279
  // - `url:` (remote source) and a bare `source:` object are carried as-is.
280
- // - `schema:` is dropped when it only restates the query-name convention default,
281
- // so a terse author file stays terse.
280
+ // - `schema:` is written as the site holds it — the query-name default included —
281
+ // except a query typed by a data key, kept short where its type can be read again.
282
282
  //
283
283
  // ⛔ THE `collections/`-PREFIX STRIP AND THE site.yml ROUTING ARE BOTH GONE, and
284
284
  // they went together. They existed because `collections.yml` sat INSIDE
@@ -342,24 +342,31 @@ function declToFileShape(wire, scope = null, own = null, keyTypes = null, author
342
342
  decl.source = source
343
343
  }
344
344
 
345
- // The default is the query's name — or, when no data schema has that name, the type the
346
- // foundation declares for the data key of that name (`data-key-types.js`), which is what a
347
- // push sent in its place. Either one stays unwritten, so the terse file stays terse.
348
- // ⛔ UNLESS THE AUTHOR WROTE IT: a `schema:` the local file already states for this query is
349
- // written back as it is. Until 2026-09-25 a pull into a working copy dropped international's
350
- // `articles: { schema: '@std/article' }`, because a section types `articles` as `@std/article`.
351
- // ⭐ A CLONE GOES BY THE MARK. Where the key's type comes only from the registered foundation a
352
- // project keeps (`markedOnly`: its foundation is a catalog ref), a query is written terse only when
353
- // the push marked it `typed_by_data_key` — the author wrote no schema — or when the author's own
354
- // file declares it with none. Unmarked, the schema was the author's, and it is written.
345
+ // ⭐ THE SCHEMA THE SITE HOLDS IS WRITTEN — one spelling per meaning [Diego, 2026-10-08]. A
346
+ // query left to its name's default (`series` → `@/series`) comes back with that schema stated:
347
+ // `series: {}` and `series: { schema: '@/series' }` read the same records, and a clone, with no
348
+ // file of the author's to look at, could not tell which was written. ⛔ Until 2026-10-08 a schema
349
+ // equal to the default stayed unwritten unless the local file stated it, so a clone turned an
350
+ // author's `series: { schema: '@/series' }` into `series: {}`.
351
+ //
352
+ // ⛔ EXCEPT A QUERY TYPED BY A DATA KEY, which is not a second spelling of anything: `team:` reads
353
+ // its own folder, `records/team/`, as the type the foundation's data key declares
354
+ // (`data-key-types.js`), where `team: { schema: '@/member' }` would read `records/member/`. It
355
+ // stays as written — short — but only where this project can read it as that type again: the
356
+ // foundation's types (its own, or the registered version a clone keeps) give its name the schema
357
+ // the site holds. Then the push's mark says so (`typed_by_data_key`), or the author's file
358
+ // declares it short, or — the foundation in this project — the file does not declare it. An
359
+ // author's stated schema is never taken for one. ⛔ With no types to read, short would mean
360
+ // `@/team`: the schema is written, and the records go to its folder, so the copy still pushes it.
355
361
  const written = name ? authored?.get(name) : undefined
356
- const typedHere = !markedOnly || wire.typed_by_data_key === true || written === null
357
- const keyType = name && !own?.has(name) && typedHere ? keyTypes?.get(name) : null
362
+ const keyType = name && !own?.has(name) ? keyTypes?.get(name) : null
358
363
  // In the author's form: the registered version names the type qualified (`@acme/member`).
359
364
  const keyDefault = keyType ? unresolveSelfScope(keyType, scope, own) : null
360
- if (d.schema && (d.schema === written || (d.schema !== defaultSchema(name) && d.schema !== keyDefault))) {
361
- decl.schema = d.schema
362
- }
365
+ const typedByKey =
366
+ keyDefault !== null &&
367
+ d.schema === keyDefault &&
368
+ (wire.typed_by_data_key === true || written === null || (written === undefined && !markedOnly))
369
+ if (d.schema && !typedByKey) decl.schema = d.schema
363
370
  setIf(decl, 'sort', d.sort)
364
371
  setIf(decl, 'where', d.where)
365
372
  setIf(decl, 'limit', d.limit)
@@ -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)
@@ -47,8 +47,7 @@ import { declarationsToQueriesYml } from './records-project.js'
47
47
  import { FILLED_SECTION_TYPE, buildRouteOf } from './site.js'
48
48
  import { translationContext, SITE_META_CONTEXT } from '../i18n/extract.js'
49
49
  import { authorableDeclaration, DECLARATION_KEYS } from '../site/fetch-shapes.js'
50
- import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent, localesDir } from './locale-sync.js'
51
- import { resolveLocaleList } from '../i18n/locales.js'
50
+ import { createTranslationCollector, writeLocaleTranslations, writeFreeformTranslations, unwrapLocalizedContent } from './locale-sync.js'
52
51
  import { buildFreeformPath, freeformPathsFor } from '../i18n/freeform.js'
53
52
  import { unwrapLocalized, unwrapLocalizedList } from './backfill.js'
54
53
  import { LOCALIZED_FIELD_ASSUMPTION } from './localize.js'
@@ -214,7 +213,7 @@ function readAuthoredYaml(filePath) {
214
213
  * @returns {{ siteConfig: string, theme?: string, headHtml?: string }} per-file
215
214
  * write status ('updated' | 'unchanged')
216
215
  */
217
- export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false, deferLanguages = false }) {
216
+ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLocale = LOCALIZED_FIELD_ASSUMPTION.defaultSourceLocale, collector, keepAuthoredFoundation = false }) {
218
217
  const info = document?.info || {}
219
218
  const settingsSection = document?.settings || {}
220
219
 
@@ -325,18 +324,14 @@ export function siteInfoToConfig({ document, siteRoot, backend = null, sourceLoc
325
324
  }
326
325
  }
327
326
 
328
- // ⭐ `languages` for a site whose `site.yml` declares none is judged once the pull has written the
329
- // translation files (`deferLanguages`, `siteContentDocumentToProject`): a push sends the list those
330
- // files make (`uwx/site.js::settingsNested`), and writing it back would turn "every translation
331
- // file" into a fixed list.
332
- let pendingLanguages
333
- if (deferLanguages && siteChanges.languages !== undefined && !('languages' in (readAuthoredYaml(join(siteRoot, 'site.yml')) || {}))) {
334
- pendingLanguages = siteChanges.languages
335
- delete siteChanges.languages
336
- }
337
-
327
+ // ⭐ `languages` IS WRITTEN AS THE SITE HOLDS IT — one spelling per meaning [Diego, 2026-10-08]:
328
+ // "It's definitely good for the case of languages." A site whose `site.yml` declared none sent the
329
+ // list its translation files make (`uwx/site.js::settingsNested`), and that list comes back stated,
330
+ // so a clone and the copy that pushed it say the same thing. ⛔ Until 2026-10-08 the list was held
331
+ // back where the translation files the pull wrote made the same one, so a clone of a site that
332
+ // declared `languages: [en, fr]` had no `languages:` at all — and followed its translation files
333
+ // where the site it came from had a fixed list.
338
334
  const result = { siteConfig: writeSiteConfig(siteRoot, siteChanges) }
339
- if (pendingLanguages !== undefined) result.pendingLanguages = pendingLanguages
340
335
 
341
336
  // theme (whole object) → theme.yml.
342
337
  if (settingsSection.theme && typeof settingsSection.theme === 'object') {
@@ -587,7 +582,7 @@ function listedNames(list) {
587
582
  //
588
583
  // ⛔ A top-level section never takes an `@` file: that is a child's name, and the build renders an
589
584
  // `@` 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 } = {}) {
585
+ export function existingSectionFile(pageDir, fileBase, stableId, { child = false } = {}) {
591
586
  if (!existsSync(pageDir)) return null
592
587
  const entries = readdirSync(pageDir).filter((e) => isMarkdownFile(e) && (child || !e.startsWith('@')))
593
588
  const found =
@@ -1121,7 +1116,7 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
1121
1116
  backend ? readBackendState(siteRoot, backend).assets || {} : {}
1122
1117
  )
1123
1118
 
1124
- report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation, deferLanguages: true })
1119
+ report.config = siteInfoToConfig({ document, siteRoot, backend, sourceLocale, collector, keepAuthoredFoundation })
1125
1120
  report.queries = declarationsToQueriesYml({ document, siteRoot, scope })
1126
1121
 
1127
1122
  // The uuid identity index (gitignored `.uniweb/`): read the prior map to anchor
@@ -1162,18 +1157,6 @@ export function siteContentDocumentToProject({ document, siteRoot, backend = nul
1162
1157
  report.locales = writeLocaleTranslations(siteRoot, collector.byLocale)
1163
1158
  // Target-locale FREE-FORM bodies → locales/freeform/{locale}/<relpath> + manifest.
1164
1159
  report.freeform = writeFreeformTranslations(siteRoot, collector.freeformPending)
1165
- // The site's languages, for a site.yml that declares none (`siteInfoToConfig`) — written only where
1166
- // the translation files, now written, would not make the same list, or where `publishLanguages`
1167
- // needs a declared list to draw from: the build refuses one without it.
1168
- if (report.config?.pendingLanguages !== undefined) {
1169
- const pulled = report.config.pendingLanguages
1170
- delete report.config.pendingLanguages
1171
- const site = readAuthoredYaml(join(siteRoot, 'site.yml')) || {}
1172
- const source = site.defaultLanguage || sourceLocale
1173
- const made = new Set([source, ...resolveLocaleList(undefined, localesDir(siteRoot))])
1174
- const same = Array.isArray(pulled) && pulled.length === made.size && pulled.every((l) => made.has(l))
1175
- if (!same || site.publishLanguages !== undefined) writeSiteConfig(siteRoot, { languages: pulled })
1176
- }
1177
1160
  writePullIndex(siteRoot, ctx.newIndex)
1178
1161
  return report
1179
1162
  }
@@ -45,9 +45,18 @@ function columnOf(text, key) {
45
45
  return /^ *$/.test(lead) ? lead.length : null
46
46
  }
47
47
 
48
- /** Where a pair's text ends: after its value, its trailing comment and its newline. */
48
+ /**
49
+ * Where a pair's text ends: after its value, its trailing comment and its newline.
50
+ *
51
+ * ⛔ A SCALAR ENDS WHERE ITS OWN TEXT DOES — `range[1]`, then the rest of that line. Its
52
+ * `range[2]` also spans the comment lines that follow it, and for an empty value (`team:`)
53
+ * every comment and blank line up to the next key, at any indentation: those belong to what
54
+ * comes next. ⛔ Until 2026-10-08 this read `range[2]`, and a pull that gave a bare `team:` its
55
+ * schema deleted the blank line and the `# Build options` heading under it in `site.yml`.
56
+ */
49
57
  function pairEnd(text, pair) {
50
- let end = (pair.value ?? pair.key).range[2]
58
+ const node = pair.value ?? pair.key
59
+ let end = isScalar(node) ? node.range[1] : node.range[2]
51
60
  if (text[end - 1] !== '\n') {
52
61
  const nl = text.indexOf('\n', end)
53
62
  const rest = nl === -1 ? text.slice(end) : text.slice(end, nl)
@@ -169,6 +178,17 @@ function editMap(text, map, before, after, edits, dump) {
169
178
  continue
170
179
  }
171
180
  }
181
+ // ⭐ A BARE KEY GIVEN A VALUE KEEPS ITS LINE — and any comment the author wrote on it — and
182
+ // the value goes under it: `team: # the people` gains ` schema: '@/team'` beneath.
183
+ const bare = isScalar(value) && value.value === null && value.range[0] === value.range[1]
184
+ if (bare && (isPlainObject(next) || Array.isArray(next))) {
185
+ const whole = indented(dump({ [key]: next }), col, false)
186
+ const nl = whole.indexOf('\n')
187
+ if (nl !== -1 && nl < whole.length - 1) {
188
+ edits.push({ start: stop, end: stop, text: (text[stop - 1] === '\n' ? '' : '\n') + whole.slice(nl + 1) })
189
+ continue
190
+ }
191
+ }
172
192
  // Written whole — in flow style when the author wrote it that way (`tags: [a, b]`).
173
193
  const flow = (isMap(value) || isSeq(value)) && value.flow
174
194
  edits.push({