@uniweb/build 0.78.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.78.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/projections": "^0.7.14",
61
- "@uniweb/content-writer": "^0.3.4",
62
60
  "@uniweb/semantic-parser": "^1.5.1",
61
+ "@uniweb/projections": "^0.7.15",
62
+ "@uniweb/content-writer": "^0.3.4",
63
+ "@uniweb/schemas": "^0.13.0",
63
64
  "@uniweb/content-reader": "^1.2.8",
64
- "@uniweb/schemas": "^0.12.0",
65
65
  "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.29.2"
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/core": "^0.37.2",
78
- "@uniweb/runtime": "^0.29.2"
77
+ "@uniweb/runtime": "^0.29.4",
78
+ "@uniweb/core": "^0.38.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -5,7 +5,7 @@ import yaml from 'js-yaml'
5
5
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
6
6
 
7
7
  /**
8
- * Where `uniweb dev` answers the site's `api` service when `$devApi` names a handler.
8
+ * Where `uniweb dev` answers the site's `backend` service when `$devBackend` names a handler.
9
9
  * The dev server is the host here, so the address is its own.
10
10
  */
11
11
  export const DEV_API_ADDRESS = '/_api'
@@ -16,22 +16,24 @@ export const DEV_API_ADDRESS = '/_api'
16
16
  * A site that talks to a backend needs one running to be developed against, and
17
17
  * making that a live deployment is slow, costs money, and puts a shared database
18
18
  * behind a developer's experiments. So a site may name a **local handler**, and in
19
- * `uniweb dev` the dev server answers the site's `api` service with it:
19
+ * `uniweb dev` the dev server answers the site's `backend` service with it:
20
20
  *
21
21
  * ```yaml
22
22
  * # site.yml
23
23
  * services:
24
- * api: true # ask your host for an app backend (in production)
25
- * $devApi: ./mock/api.js # what answers it in `uniweb dev`, at /_api
24
+ * backend: true # ask your host for it (in production)
25
+ * $devBackend: ./mock/api.js # what answers it in `uniweb dev`, at /_api
26
26
  * ```
27
27
  *
28
28
  * ⭐ THE DEV SERVER SUPPLIES THE ADDRESS [Diego, 2026-10-06]. In `uniweb dev` it is
29
29
  * the host, so where it answers is its own to choose — `DEV_API_ADDRESS` — and the
30
- * plugin puts that address in the dev payload's `config.api`, the site tier
30
+ * plugin puts that address in the dev payload's `config.backend`, the site tier
31
31
  * `resolveService` reads when no host speaks. ⛔ *Until then the site wrote it, as a
32
32
  * top-level `api: /_api` "the same in development and in production" — an address
33
33
  * that, under `services:`, would read as "the site brings its own backend" and ask the
34
- * host to turn its own off.*
34
+ * host to turn its own off.* ⛔ *And until 2026-10-07 the service was `api` and this
35
+ * key `$devApi`; both are refused by those names now — `services.api` by
36
+ * `refuseUnreadableServices`, `$devApi` by `refuseRetiredServiceKeys`.*
35
37
  *
36
38
  * ```js
37
39
  * // mock/api.js — default-export a fetch handler
@@ -50,9 +52,9 @@ export const DEV_API_ADDRESS = '/_api'
50
52
  *
51
53
  * ## ⛔ Development only, and it cannot leak
52
54
  *
53
- * `$devApi` is read by the dev plugin and by nothing else: no build reads it, no
55
+ * `$devBackend` is read by the dev plugin and by nothing else: no build reads it, no
54
56
  * `info` key carries it, and only the dev server's payload names its address. What
55
- * answers the site's `api` service locally is a fact about one machine.
57
+ * answers the site's `backend` service locally is a fact about one machine.
56
58
  *
57
59
  * ⚠️ **Same-origin on purpose.** Mounting inside the dev server means cookies and
58
60
  * `credentials: 'same-origin'` behave as they do in production, where a site's app
@@ -87,7 +89,7 @@ export function mountDevApi(server, { root }) {
87
89
  return null
88
90
  }
89
91
 
90
- const spec = site.$devApi
92
+ const spec = site.$devBackend
91
93
  if (!spec) return null
92
94
  const mount = DEV_API_ADDRESS
93
95
 
@@ -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
+ }
@@ -75,9 +75,10 @@
75
75
  *
76
76
  * ⚠️ `@uniweb/api` deliberately needs no entry: it calls
77
77
  * `resolveService(website, SERVICE_NAME)` against a module-level `const`, which
78
- * `readModuleConsts` resolves. Without that resolution `api` would be both
78
+ * `readModuleConsts` resolves. Without that resolution `backend` would be both
79
79
  * missed *and* counted as blindness, marking every foundation that uses it
80
- * unknowable.
80
+ * unknowable. ⛔ *The constant was `api` until 2026-10-07, so a foundation built
81
+ * against an earlier `@uniweb/api` derives `api` — a service no host offers now.*
81
82
  *
82
83
  * ## Why the AST rather than a regex
83
84
  *
@@ -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,
@@ -161,7 +161,8 @@ export function collectSchemaRefs(components, foundationData = null) {
161
161
  const readData = (data) => {
162
162
  if (!data || typeof data !== 'object' || data === false) return
163
163
  for (const binding of Object.values(data)) {
164
- // `'@std/article/*'` asks for whole records of `@std/article` — the ref is what precedes it.
164
+ // A ref, short or long (`{ schema, single, whole }`) — the flags change nothing about which
165
+ // schema the key is typed by.
165
166
  const { ref } = dataRefOf(binding)
166
167
  if (ref) refs.add(ref)
167
168
  }
@@ -5,8 +5,9 @@
5
5
  * reads to render, and nothing else:
6
6
  *
7
7
  * - background: 'self' when component handles its own background
8
- * - data: { <key>: <schema ref> | null } — every `content.data` key the component
9
- * declares, in order, with its schema ref (null for an inline shape)
8
+ * - data: { <key>: <schema ref> | { schema, single?, whole? } | null } — every `content.data`
9
+ * key the component declares, in order, with its schema ref and the flags set on it (null for
10
+ * an inline shape)
10
11
  * - vars: { <name>: { default? } } — the CSS variables the component declares
11
12
  * - defaults: param default values
12
13
  * - context: static capabilities for cross-block coordination
@@ -34,17 +35,34 @@
34
35
  */
35
36
 
36
37
  import { lowerData } from '@uniweb/schemas/content'
38
+ import { schemaDeclarationOf } from '@uniweb/schemas/foundation'
37
39
 
38
40
  /**
39
- * A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
40
- * shape, which names no schema and is filled only under its own key.
41
+ * A `data:` entry as the runtime reads it: its schema ref, with the flags set on it — or null for an
42
+ * inline shape, which names no schema and is filled only under its own key.
41
43
  *
42
- * @param {string|Object} value
43
- * @returns {string|null}
44
+ * ⭐ Two flags, independent (ruled 2026-10-07 [Diego]): `single` — one record, not a list — and
45
+ * `whole` — each record as stored, not its brief.
46
+ * Lean on purpose: the bare ref when neither is set, which is most keys, and only a flag that is set.
47
+ * ⛔ Until 2026-10-07 this wrote the ref alone, so `{ schema, whole: true }` lost its flag here and
48
+ * only the `'…/*'` spelling, now retired, reached the runtime.
49
+ *
50
+ * @param {string} key - the key, for a refusal
51
+ * @param {*} value - its value, as authored
52
+ * @returns {string|{ schema: string, single?: true, whole?: true }|null}
53
+ * @throws {Error} for a retired or malformed declaration (`schemaDeclarationOf`)
44
54
  */
45
- function dataRef(value) {
46
- if (typeof value === 'string') return value || null
47
- return value && typeof value === 'object' && typeof value.schema === 'string' && value.schema ? value.schema : null
55
+ function leanDataValue(key, value) {
56
+ let declaration
57
+ try {
58
+ declaration = schemaDeclarationOf(value)
59
+ } catch (err) {
60
+ throw new Error(`[uniweb] Invalid 'data.${key}': ${err.message}`)
61
+ }
62
+ if (!declaration || !declaration.schema) return null
63
+ const { schema, single, whole } = declaration
64
+ if (!single && !whole) return schema
65
+ return { schema, ...(single ? { single: true } : {}), ...(whole ? { whole: true } : {}) }
48
66
  }
49
67
 
50
68
  /**
@@ -148,7 +166,7 @@ export function extractRuntimeSchema(fullMeta) {
148
166
  )
149
167
  }
150
168
  runtime.data = runtime.data || {}
151
- runtime.data[key] = dataRef(value)
169
+ runtime.data[key] = leanDataValue(key, value)
152
170
  }
153
171
  } else if (data !== undefined) {
154
172
  throw new Error(
@@ -337,12 +355,12 @@ function leanVars(vars) {
337
355
  return Object.keys(out).length > 0 ? out : null
338
356
  }
339
357
 
340
- /** A foundation's `data:` as a section type's lean `data` is: each key → its schema ref, or null. */
358
+ /** A foundation's `data:` as a section type's lean `data` is: each key → its ref and flags, or null. */
341
359
  function leanDataKeys(data) {
342
360
  const lowered = lowerData(data)
343
361
  if (!lowered || typeof lowered !== 'object' || Array.isArray(lowered)) return null
344
362
  const out = {}
345
- for (const [key, value] of Object.entries(lowered)) out[key] = dataRef(value)
363
+ for (const [key, value] of Object.entries(lowered)) out[key] = leanDataValue(key, value)
346
364
  return Object.keys(out).length > 0 ? out : null
347
365
  }
348
366
 
package/src/schema.js CHANGED
@@ -18,7 +18,8 @@ import { join, dirname, extname, basename } from 'node:path'
18
18
  import { pathToFileURL } from 'node:url'
19
19
  import { describeChildren, describeVisuals, lowerChildren } from '@uniweb/schemas/component'
20
20
  import { describeContent, lowerData } from '@uniweb/schemas/content'
21
- import { normalizeData } from '@uniweb/schemas/foundation'
21
+ import { normalizeData, schemaDeclarationOf } from '@uniweb/schemas/foundation'
22
+ import { rootListSection } from '@uniweb/schemas/conform'
22
23
  import { SECTION_PARAMS, SECTION_KEYS } from '@uniweb/schemas/section'
23
24
  import { collectSchemaRefs, buildDataSchemaMap, ownSchemaRefs } from './resolve-data-schema.js'
24
25
  import {
@@ -26,6 +27,8 @@ import {
26
27
  deriveRecordsSupport,
27
28
  unnameableIn,
28
29
  } from './foundation/derive-supports.js'
30
+ import { RENAMED_SERVICES } from './uwx/services-request.js'
31
+ import { resolveDefaultLayout } from './foundation/default-layout.js'
29
32
  import { foundationNameOf, splitFoundationName } from './foundation-name.js'
30
33
  import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
31
34
 
@@ -635,12 +638,67 @@ function buildComponentEntry(name, relativePath, meta) {
635
638
  else delete entry.children
636
639
 
637
640
  if (meta.data !== undefined) entry.data = lowerData(meta.data)
641
+ refuseDataDeclarations(`${name} (meta.js)`, entry.data)
638
642
 
639
643
  const problems = [...content.problems, ...describeChildren(meta).problems]
640
644
  if (problems.length > 0) loweringProblems.set(entry, problems)
641
645
  return entry
642
646
  }
643
647
 
648
+ /**
649
+ * Refuse a `data:` key typed by a schema that is not written as one: the retired `/*` suffix, a
650
+ * property of the long form other than `schema`, `single` and `whole`, or a flag that is not a
651
+ * boolean (`schemaDeclarationOf`; ruled 2026-10-07 [Diego]). A refusal, not a warning: the runtime would
652
+ * read a mistyped flag as absent, and deliver a list where the author asked for one record.
653
+ *
654
+ * @param {string} owner - who declares it, for the message
655
+ * @param {*} data - a `data:` map, lowered
656
+ */
657
+ function refuseDataDeclarations(owner, data) {
658
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return
659
+ for (const [key, value] of Object.entries(data)) {
660
+ try {
661
+ schemaDeclarationOf(value)
662
+ } catch (err) {
663
+ throw new Error(`[uniweb] ${owner}: data.${key}: ${err.message}`)
664
+ }
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Refuse `single` or `whole` on a key whose schema is a LIST — `@std/nav`, `@std/form`: a schema
670
+ * whose root is one list section describes the key's whole value, so the key holds that list, and
671
+ * neither one record of it nor a record as stored means anything. Checked once the schemas resolve.
672
+ *
673
+ * @param {Array<[string, *]>} owners - `[who, data:]` pairs
674
+ * @param {Object} dataSchemas - normalized schemas by ref
675
+ * @param {string} srcDir - to resolve a ref no binding brought in (a layout's)
676
+ */
677
+ async function refuseFlagsOnListSchemas(owners, dataSchemas, srcDir) {
678
+ for (const [owner, data] of owners) {
679
+ if (!data || typeof data !== 'object' || Array.isArray(data)) continue
680
+ for (const [key, value] of Object.entries(data)) {
681
+ const declaration = schemaDeclarationOf(value)
682
+ if (!declaration || (!declaration.single && !declaration.whole)) continue
683
+ let schema = dataSchemas[declaration.schema]
684
+ if (!schema) {
685
+ try {
686
+ schema = (await buildDataSchemaMap([declaration.schema], { srcDir }))[declaration.schema]
687
+ } catch {
688
+ schema = null // an unresolvable ref is reported where refs resolve
689
+ }
690
+ }
691
+ if (schema && rootListSection(schema)) {
692
+ const flags = ['single', 'whole'].filter((flag) => declaration[flag]).map((flag) => `\`${flag}\``).join(' and ')
693
+ throw new Error(
694
+ `[uniweb] ${owner}: data.${key}: ${declaration.schema} is a list — its key holds that list, so ${flags} ` +
695
+ `does not apply. Write data.${key}: '${declaration.schema}'.`
696
+ )
697
+ }
698
+ }
699
+ }
700
+ }
701
+
644
702
  /** `content: {}` or `content: []` — the component's way of saying it takes no content. */
645
703
  function declaresNoContent(content) {
646
704
  if (Array.isArray(content)) return content.length === 0
@@ -954,6 +1012,29 @@ export async function discoverComponents(srcDir, sectionPaths = DEFAULT_SECTION_
954
1012
  return sections
955
1013
  }
956
1014
 
1015
+ /**
1016
+ * Stop on a service `uniweb.supports` names by its old name (`RENAMED_SERVICES`), as
1017
+ * `site.yml::services` does. A foundation claiming `api` claims a service no host offers
1018
+ * now — and the build derives `backend` from the code itself, through kit's and
1019
+ * `@uniweb/api`'s literals, so a renamed claim could only ever disagree with it.
1020
+ *
1021
+ * ⛔ Before the dev-rebuild bail, like `reportSupports`' warning: dev is where the
1022
+ * developer is working.
1023
+ *
1024
+ * @param {string} srcDir
1025
+ * @param {string[]|undefined} authored - normalized `uniweb.supports`
1026
+ * @throws {Error} naming each old name and the new one
1027
+ */
1028
+ function refuseRenamedSupports(srcDir, authored) {
1029
+ const renamed = (authored || []).filter((s) => RENAMED_SERVICES[s])
1030
+ if (!renamed.length) return
1031
+ throw new Error(
1032
+ `${srcDir}/package.json: \`uniweb.supports\` names ${renamed.map((s) => `\`${s}\``).join(', ')}, which is now ` +
1033
+ `${renamed.map((s) => `\`${RENAMED_SERVICES[s]}\``).join(', ')} — the site's own backend is the \`backend\` ` +
1034
+ 'service. Rename it in package.json.',
1035
+ )
1036
+ }
1037
+
957
1038
  /**
958
1039
  * Say what the derivation added, at the one moment the developer is looking.
959
1040
  *
@@ -1138,6 +1219,8 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1138
1219
  // Load configuration from foundation.js
1139
1220
  const foundationConfig = await loadFoundationConfig(srcDir)
1140
1221
 
1222
+ refuseDataDeclarations('the foundation (main.js)', lowerData(foundationConfig.data))
1223
+
1141
1224
  // Discover section types
1142
1225
  const components = await discoverComponents(srcDir, sectionPaths)
1143
1226
  reportPlacementDeclarations(components)
@@ -1154,6 +1237,18 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1154
1237
 
1155
1238
  // Discover layouts from src/layouts/
1156
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))
1243
+ await refuseFlagsOnListSchemas(
1244
+ [
1245
+ ...Object.entries(components).map(([name, entry]) => [`${name} (meta.js)`, entry?.data]),
1246
+ ...Object.entries(layouts).map(([name, entry]) => [`layout ${name} (meta.js)`, entry?.data]),
1247
+ ['the foundation (main.js)', lowerData(foundationConfig.data)],
1248
+ ],
1249
+ dataSchemas,
1250
+ srcDir,
1251
+ )
1157
1252
  reportUnregistrableData(components, layouts, foundationConfig)
1158
1253
 
1159
1254
  // Determine extension role
@@ -1169,8 +1264,9 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1169
1264
  }
1170
1265
  }
1171
1266
 
1172
- // Build _self, stripping the raw extension boolean in favor of normalized role
1173
- 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
1174
1270
 
1175
1271
  // `supports` is the one identity field the build knows better than the file.
1176
1272
  // `identity.supports` is already normalized (absent stays absent, `[]` stays
@@ -1188,6 +1284,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1188
1284
  // `supports` differs from the one a real build produces is a trap rather than a
1189
1285
  // convenience — the artifact would disagree with itself depending on which
1190
1286
  // command wrote it. One rule: the derivation runs on a real build.
1287
+ refuseRenamedSupports(srcDir, identity.supports)
1191
1288
  const derived = derivedSupports && {
1192
1289
  ...derivedSupports,
1193
1290
  services: deriveRecordsSupport(components, foundationConfig.data)
@@ -1211,7 +1308,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1211
1308
  ...supports,
1212
1309
  ...(name && { name }),
1213
1310
  ...(foundationConfig.description && { description: foundationConfig.description }),
1214
- ...(foundationConfig.defaultLayout && { defaultLayout: foundationConfig.defaultLayout }),
1311
+ ...(defaultLayout && { defaultLayout }),
1215
1312
  ...(isExtension && { role: 'extension' }),
1216
1313
  // The build's fact, last: which form the entries below hold.
1217
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'
@@ -41,7 +41,7 @@ import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig
41
41
  import { authoredRedirectTarget } from '@uniweb/core/resolve-route'
42
42
  import { parseFrontmatter } from '../utils/frontmatter.js'
43
43
  import { parseGrid } from '@uniweb/schemas/grid'
44
- import { runtimeServicesConfig, refuseRetiredServiceKeys } from '../uwx/services-request.js'
44
+ import { runtimeServicesConfig, refuseRetiredServiceKeys, refuseUnreadableServices } from '../uwx/services-request.js'
45
45
 
46
46
  // Try to import content-reader, fall back to simplified parser
47
47
  let markdownToProseMirror
@@ -2503,6 +2503,7 @@ export async function collectSiteContent(sitePath, options = {}) {
2503
2503
  // Read site config and raw theme config
2504
2504
  const siteConfig = await readYamlFile(join(sitePath, configFile))
2505
2505
  refuseRetiredServiceKeys(siteConfig, configFile)
2506
+ refuseUnreadableServices(siteConfig, configFile)
2506
2507
 
2507
2508
  // Queries are declared in TWO files — `site.yml::queries` and `queries.yml`,
2508
2509
  // the latter winning per key — and resolving them is one question with one
@@ -2510,13 +2511,17 @@ export async function collectSiteContent(sitePath, options = {}) {
2510
2511
  // merged both, so a declaration in the second file was invisible here: never
2511
2512
  // compiled, `data: <name>` delivering nothing, while sync pushed it fine.
2512
2513
  //
2513
- // ⭐ `config.queries` — framework's own payload key, framework's own readers.
2514
- // The backend's projector never emits it (their measurement: 17 `config` keys,
2515
- // not this one), and hosting renders with framework's code. There was nobody to
2516
- // coordinate with, which is exactly why it had no excuse to stay wrong.
2517
- const byQuery = toConfigQueries(
2518
- (await resolveQueriesConfig(sitePath, { siteYml: siteConfig })).declarations
2519
- )
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)
2520
2525
  if (byQuery) siteConfig.queries = byQuery
2521
2526
 
2522
2527
  // Record the RESOLVED base (--base > UNIWEB_BASE > site.yml::base) on the
@@ -7,8 +7,10 @@
7
7
  * must not destroy a developer's comments on the targets:/saveDeploys:
8
8
  * regions of the file.
9
9
  *
10
- * This is the only place in @uniweb/build that depends on `yaml`. The
11
- * loader (deploy-config.js) stays on js-yaml for read-only ingestion.
10
+ * The loader (deploy-config.js) stays on js-yaml for read-only ingestion.
11
+ * ⚠️ This read "the only place in @uniweb/build that depends on `yaml`" until
12
+ * 2026-10-07; the pull's config writers locate entries with it too
13
+ * (`uwx/yaml-edit.js::editYamlText`), and print with js-yaml.
12
14
  *
13
15
  * `saveDeploys: false` (or `--no-save`) makes this a no-op; otherwise it
14
16
  * touches ONLY deploys.<targetName> — plus targets.<targetName> when the file has no
@@ -655,16 +655,16 @@ export function siteContentPlugin(options = {}) {
655
655
  }
656
656
 
657
657
  let siteContent = null
658
- // Where `uniweb dev` answers the site's `api` service with `$devApi`'s handler — set
658
+ // Where `uniweb dev` answers the site's `backend` service with `$devBackend`'s handler — set
659
659
  // in `configureServer`, so never in a build.
660
660
  let devApiAddress = null
661
661
  /**
662
- * The dev payload names `$devApi`'s address as the site's `api` service: the site
662
+ * The dev payload names `$devBackend`'s address as the site's `backend` service: the site
663
663
  * tier, which `resolveService` reads when no host speaks. A build never gets here
664
664
  * with one.
665
665
  */
666
666
  const withDevApi = (content) => {
667
- if (devApiAddress && content?.config) content.config.api = devApiAddress
667
+ if (devApiAddress && content?.config) content.config.backend = devApiAddress
668
668
  return content
669
669
  }
670
670
  // What each section type declares its data blocks are — read once per collection of the site.
@@ -1009,10 +1009,10 @@ export function siteContentPlugin(options = {}) {
1009
1009
  configureServer(devServer) {
1010
1010
  server = devServer
1011
1011
 
1012
- // A site's own backend, answered locally in development. `site.yml::$devApi`
1012
+ // A site's own backend, answered locally in development. `site.yml::$devBackend`
1013
1013
  // names a module that default-exports a fetch handler; the dev server mounts it
1014
1014
  // at an address of its own and names that address in the payload it serves
1015
- // (`withDevApi`), so the site's `api` service resolves to it.
1015
+ // (`withDevApi`), so the site's `backend` service resolves to it.
1016
1016
  // ⚠️ Synchronous on purpose — see mountDevApi. An await here and the
1017
1017
  // middleware lands after Vite's SPA fallback, which answers the API with
1018
1018
  // index.html and says nothing about why.