@uniweb/build 0.77.0 → 0.79.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.77.0",
3
+ "version": "0.79.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.15",
61
+ "@uniweb/content-writer": "^0.3.4",
60
62
  "@uniweb/semantic-parser": "^1.5.1",
61
- "@uniweb/theming": "^0.2.0",
62
- "@uniweb/schemas": "^0.12.0",
63
+ "@uniweb/schemas": "^0.13.0",
63
64
  "@uniweb/content-reader": "^1.2.8",
64
- "@uniweb/projections": "^0.7.14",
65
- "@uniweb/content-writer": "^0.3.4"
65
+ "@uniweb/theming": "^0.2.0"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.29.2"
68
+ "@uniweb/runtime": "^0.29.3"
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.3",
78
+ "@uniweb/core": "^0.38.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
@@ -19,7 +19,6 @@ export { collectSiteContent } from '../site/content-collector.js'
19
19
  export {
20
20
  processQueries,
21
21
  writeQueryFiles,
22
- getQueryLastModified,
23
22
  } from '../site/query-processor.js'
24
23
  // Where a site's records live — `paths.records`, else `records/` — so a caller
25
24
  // holding its own config (a document's, not a `site.yml`) asks the one resolver.
@@ -4,20 +4,37 @@ import { pathToFileURL } from 'node:url'
4
4
  import yaml from 'js-yaml'
5
5
  import { YAML_OPTIONS } from '../utils/yaml-schema.js'
6
6
 
7
+ /**
8
+ * Where `uniweb dev` answers the site's `backend` service when `$devBackend` names a handler.
9
+ * The dev server is the host here, so the address is its own.
10
+ */
11
+ export const DEV_API_ADDRESS = '/_api'
12
+
7
13
  /**
8
14
  * Mount a site's own request handler in the dev server.
9
15
  *
10
16
  * A site that talks to a backend needs one running to be developed against, and
11
17
  * making that a live deployment is slow, costs money, and puts a shared database
12
- * behind a developer's experiments. So a site may name a **local handler** and the
13
- * dev server mounts it at the site's own service address:
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 `backend` service with it:
14
20
  *
15
21
  * ```yaml
16
22
  * # site.yml
17
- * api: /_api # where the site's app backend answers
18
- * devApi: ./mock/api.js # what answers it, in development only
23
+ * services:
24
+ * backend: true # ask your host for it (in production)
25
+ * $devBackend: ./mock/api.js # what answers it in `uniweb dev`, at /_api
19
26
  * ```
20
27
  *
28
+ * ⭐ THE DEV SERVER SUPPLIES THE ADDRESS [Diego, 2026-10-06]. In `uniweb dev` it is
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.backend`, the site tier
31
+ * `resolveService` reads when no host speaks. ⛔ *Until then the site wrote it, as a
32
+ * top-level `api: /_api` "the same in development and in production" — an address
33
+ * that, under `services:`, would read as "the site brings its own backend" and ask the
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`.*
37
+ *
21
38
  * ```js
22
39
  * // mock/api.js — default-export a fetch handler
23
40
  * export default (request) => new Response('{}', { headers: { 'content-type': 'application/json' } })
@@ -35,10 +52,9 @@ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
35
52
  *
36
53
  * ## ⛔ Development only, and it cannot leak
37
54
  *
38
- * `devApi` is read by the dev plugin and by nothing else: no build reads it, no
39
- * `info` key carries it, and nothing writes it into a payload. A site's *address*
40
- * (`api:`) is authored config and travels; what answers that address locally is a
41
- * fact about one machine.
55
+ * `$devBackend` is read by the dev plugin and by nothing else: no build reads it, no
56
+ * `info` key carries it, and only the dev server's payload names its address. What
57
+ * answers the site's `backend` service locally is a fact about one machine.
42
58
  *
43
59
  * ⚠️ **Same-origin on purpose.** Mounting inside the dev server means cookies and
44
60
  * `credentials: 'same-origin'` behave as they do in production, where a site's app
@@ -59,7 +75,7 @@ import { YAML_OPTIONS } from '../utils/yaml-schema.js'
59
75
  * @param {import('vite').ViteDevServer} server
60
76
  * @param {object} options
61
77
  * @param {string} options.root - the site directory
62
- * @returns {boolean} whether a handler was mounted
78
+ * @returns {string|null} the address the handler answers on, or null when none was mounted
63
79
  */
64
80
  export function mountDevApi(server, { root }) {
65
81
  // ⛔ Read from the RAW site.yml, never from the collected `config`. `$`-prefixed
@@ -70,18 +86,12 @@ export function mountDevApi(server, { root }) {
70
86
  try {
71
87
  site = yaml.load(readFileSync(join(root, 'site.yml'), 'utf8'), YAML_OPTIONS) || {}
72
88
  } catch {
73
- return false
89
+ return null
74
90
  }
75
91
 
76
- const spec = site.$devApi
77
- if (!spec) return false
78
-
79
- const declared = site.api
80
- const mount = typeof declared === 'string' ? declared : declared?.endpoint
81
- if (!mount) {
82
- console.error("[dev-api] `$devApi` needs an `api:` address to answer on — add `api: /_api` to site.yml.")
83
- return false
84
- }
92
+ const spec = site.$devBackend
93
+ if (!spec) return null
94
+ const mount = DEV_API_ADDRESS
85
95
 
86
96
  // Loaded once, lazily, and awaited by the middleware. ⚠️ Loud and specific on
87
97
  // failure: a dev API that silently fails to load looks exactly like a backend
@@ -139,5 +149,5 @@ export function mountDevApi(server, { root }) {
139
149
  })
140
150
 
141
151
  console.log(`[dev-api] '${spec}' answering ${prefix}/*`)
142
- return true
152
+ return mount
143
153
  }
@@ -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
  *
@@ -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,7 @@ import {
26
27
  deriveRecordsSupport,
27
28
  unnameableIn,
28
29
  } from './foundation/derive-supports.js'
30
+ import { RENAMED_SERVICES } from './uwx/services-request.js'
29
31
  import { foundationNameOf, splitFoundationName } from './foundation-name.js'
30
32
  import { resolveFoundationSrcDir } from './utils/foundation-source-root.js'
31
33
 
@@ -635,12 +637,67 @@ function buildComponentEntry(name, relativePath, meta) {
635
637
  else delete entry.children
636
638
 
637
639
  if (meta.data !== undefined) entry.data = lowerData(meta.data)
640
+ refuseDataDeclarations(`${name} (meta.js)`, entry.data)
638
641
 
639
642
  const problems = [...content.problems, ...describeChildren(meta).problems]
640
643
  if (problems.length > 0) loweringProblems.set(entry, problems)
641
644
  return entry
642
645
  }
643
646
 
647
+ /**
648
+ * Refuse a `data:` key typed by a schema that is not written as one: the retired `/*` suffix, a
649
+ * property of the long form other than `schema`, `single` and `whole`, or a flag that is not a
650
+ * boolean (`schemaDeclarationOf`; ruled 2026-10-07 [Diego]). A refusal, not a warning: the runtime would
651
+ * read a mistyped flag as absent, and deliver a list where the author asked for one record.
652
+ *
653
+ * @param {string} owner - who declares it, for the message
654
+ * @param {*} data - a `data:` map, lowered
655
+ */
656
+ function refuseDataDeclarations(owner, data) {
657
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return
658
+ for (const [key, value] of Object.entries(data)) {
659
+ try {
660
+ schemaDeclarationOf(value)
661
+ } catch (err) {
662
+ throw new Error(`[uniweb] ${owner}: data.${key}: ${err.message}`)
663
+ }
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Refuse `single` or `whole` on a key whose schema is a LIST — `@std/nav`, `@std/form`: a schema
669
+ * whose root is one list section describes the key's whole value, so the key holds that list, and
670
+ * neither one record of it nor a record as stored means anything. Checked once the schemas resolve.
671
+ *
672
+ * @param {Array<[string, *]>} owners - `[who, data:]` pairs
673
+ * @param {Object} dataSchemas - normalized schemas by ref
674
+ * @param {string} srcDir - to resolve a ref no binding brought in (a layout's)
675
+ */
676
+ async function refuseFlagsOnListSchemas(owners, dataSchemas, srcDir) {
677
+ for (const [owner, data] of owners) {
678
+ if (!data || typeof data !== 'object' || Array.isArray(data)) continue
679
+ for (const [key, value] of Object.entries(data)) {
680
+ const declaration = schemaDeclarationOf(value)
681
+ if (!declaration || (!declaration.single && !declaration.whole)) continue
682
+ let schema = dataSchemas[declaration.schema]
683
+ if (!schema) {
684
+ try {
685
+ schema = (await buildDataSchemaMap([declaration.schema], { srcDir }))[declaration.schema]
686
+ } catch {
687
+ schema = null // an unresolvable ref is reported where refs resolve
688
+ }
689
+ }
690
+ if (schema && rootListSection(schema)) {
691
+ const flags = ['single', 'whole'].filter((flag) => declaration[flag]).map((flag) => `\`${flag}\``).join(' and ')
692
+ throw new Error(
693
+ `[uniweb] ${owner}: data.${key}: ${declaration.schema} is a list — its key holds that list, so ${flags} ` +
694
+ `does not apply. Write data.${key}: '${declaration.schema}'.`
695
+ )
696
+ }
697
+ }
698
+ }
699
+ }
700
+
644
701
  /** `content: {}` or `content: []` — the component's way of saying it takes no content. */
645
702
  function declaresNoContent(content) {
646
703
  if (Array.isArray(content)) return content.length === 0
@@ -954,6 +1011,29 @@ export async function discoverComponents(srcDir, sectionPaths = DEFAULT_SECTION_
954
1011
  return sections
955
1012
  }
956
1013
 
1014
+ /**
1015
+ * Stop on a service `uniweb.supports` names by its old name (`RENAMED_SERVICES`), as
1016
+ * `site.yml::services` does. A foundation claiming `api` claims a service no host offers
1017
+ * now — and the build derives `backend` from the code itself, through kit's and
1018
+ * `@uniweb/api`'s literals, so a renamed claim could only ever disagree with it.
1019
+ *
1020
+ * ⛔ Before the dev-rebuild bail, like `reportSupports`' warning: dev is where the
1021
+ * developer is working.
1022
+ *
1023
+ * @param {string} srcDir
1024
+ * @param {string[]|undefined} authored - normalized `uniweb.supports`
1025
+ * @throws {Error} naming each old name and the new one
1026
+ */
1027
+ function refuseRenamedSupports(srcDir, authored) {
1028
+ const renamed = (authored || []).filter((s) => RENAMED_SERVICES[s])
1029
+ if (!renamed.length) return
1030
+ throw new Error(
1031
+ `${srcDir}/package.json: \`uniweb.supports\` names ${renamed.map((s) => `\`${s}\``).join(', ')}, which is now ` +
1032
+ `${renamed.map((s) => `\`${RENAMED_SERVICES[s]}\``).join(', ')} — the site's own backend is the \`backend\` ` +
1033
+ 'service. Rename it in package.json.',
1034
+ )
1035
+ }
1036
+
957
1037
  /**
958
1038
  * Say what the derivation added, at the one moment the developer is looking.
959
1039
  *
@@ -1138,6 +1218,8 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1138
1218
  // Load configuration from foundation.js
1139
1219
  const foundationConfig = await loadFoundationConfig(srcDir)
1140
1220
 
1221
+ refuseDataDeclarations('the foundation (main.js)', lowerData(foundationConfig.data))
1222
+
1141
1223
  // Discover section types
1142
1224
  const components = await discoverComponents(srcDir, sectionPaths)
1143
1225
  reportPlacementDeclarations(components)
@@ -1154,6 +1236,15 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1154
1236
 
1155
1237
  // Discover layouts from src/layouts/
1156
1238
  const layouts = await discoverLayoutsInPath(srcDir)
1239
+ await refuseFlagsOnListSchemas(
1240
+ [
1241
+ ...Object.entries(components).map(([name, entry]) => [`${name} (meta.js)`, entry?.data]),
1242
+ ...Object.entries(layouts).map(([name, entry]) => [`layout ${name} (meta.js)`, entry?.data]),
1243
+ ['the foundation (main.js)', lowerData(foundationConfig.data)],
1244
+ ],
1245
+ dataSchemas,
1246
+ srcDir,
1247
+ )
1157
1248
  reportUnregistrableData(components, layouts, foundationConfig)
1158
1249
 
1159
1250
  // Determine extension role
@@ -1188,6 +1279,7 @@ export async function buildSchema(srcDir, sectionPaths, derivedSupports = null)
1188
1279
  // `supports` differs from the one a real build produces is a trap rather than a
1189
1280
  // convenience — the artifact would disagree with itself depending on which
1190
1281
  // command wrote it. One rule: the derivation runs on a real build.
1282
+ refuseRenamedSupports(srcDir, identity.supports)
1191
1283
  const derived = derivedSupports && {
1192
1284
  ...derivedSupports,
1193
1285
  services: deriveRecordsSupport(components, foundationConfig.data)
@@ -41,6 +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, refuseUnreadableServices } from '../uwx/services-request.js'
44
45
 
45
46
  // Try to import content-reader, fall back to simplified parser
46
47
  let markdownToProseMirror
@@ -2448,11 +2449,13 @@ async function collectLayouts(layoutDir, siteRoot) {
2448
2449
  * `config.fetch`, desugared by the caller; carried raw it would sit beside
2449
2450
  * `config.queries`, the declarations, and read as one.
2450
2451
  *
2451
- * ⛔ `services` is the owner's REQUEST to their host (`uwx/services-request.js`), and
2452
- * here it would land at `config.services` — the HOST tier, where a present block
2453
- * declines every service it does not name. A request is never an answer, so it never
2454
- * ships. *(Until 2026-10-06 it did, and doubled as an undocumented way to simulate a
2455
- * host's offer locally.)*
2452
+ * ⭐ `services` ships as each service's SITE TIER, `config.<name>`
2453
+ * (`runtimeServicesConfig`), and never whole: at `config.services` it would be the HOST
2454
+ * tier, where a present block declines every service it does not name, and its
2455
+ * request half is not an answer. *(Until 2026-10-06 the whole block shipped there, and
2456
+ * doubled as an undocumented way to simulate a host's offer locally; the site tier was
2457
+ * the top-level `search:` / `submit:` / `assistant:` / `tracking:` / `api:` keys,
2458
+ * refused now.)*
2456
2459
  *
2457
2460
  * ⛔ `$`-prefixed keys were the project's BACKEND-SCOPED state — `$uuid`, `$org`,
2458
2461
  * `$backend`, `$services`, `$secrets` — until they moved to `sync.json` (2026-09-20),
@@ -2470,12 +2473,22 @@ function payloadSiteConfig(siteConfig) {
2470
2473
  const {
2471
2474
  publishLanguages: _publishLanguages,
2472
2475
  query: _query,
2473
- services: _services,
2476
+ services,
2474
2477
  ...runtimeSiteConfig
2475
2478
  } = siteConfig
2476
2479
  for (const key of Object.keys(runtimeSiteConfig)) {
2477
2480
  if (key.startsWith('$')) delete runtimeSiteConfig[key]
2478
2481
  }
2482
+ // ⭐ Each service's SITE TIER, from its entry: `config.<name>` — the switch, an
2483
+ // address the site brings, its options — never the request, and never a credential.
2484
+ const warn = (m) => console.warn(`[site-content] ${m}`)
2485
+ for (const [name, value] of Object.entries(runtimeServicesConfig(services, { warn }))) {
2486
+ if (name in runtimeSiteConfig) {
2487
+ warn(`\`services.${name}\` names a site.yml key that is not a service — ignoring it.`)
2488
+ continue
2489
+ }
2490
+ runtimeSiteConfig[name] = value
2491
+ }
2479
2492
  return runtimeSiteConfig
2480
2493
  }
2481
2494
 
@@ -2489,6 +2502,8 @@ export async function collectSiteContent(sitePath, options = {}) {
2489
2502
 
2490
2503
  // Read site config and raw theme config
2491
2504
  const siteConfig = await readYamlFile(join(sitePath, configFile))
2505
+ refuseRetiredServiceKeys(siteConfig, configFile)
2506
+ refuseUnreadableServices(siteConfig, configFile)
2492
2507
 
2493
2508
  // Queries are declared in TWO files — `site.yml::queries` and `queries.yml`,
2494
2509
  // the latter winning per key — and resolving them is one question with one
@@ -203,6 +203,24 @@ export function refuseUnder(where, context) {
203
203
  walk(where)
204
204
  }
205
205
 
206
+ /**
207
+ * ⛔ `filter:` IS RETIRED (2026-10-06 [Diego]: "it should not be supported. We don't need to keep
208
+ * legacy concepts around") — refused on a query and on a fetch. It was the string predicate
209
+ * (`tags contains featured`) that `where:` replaced. A fetch has not read it since 2026-09-05; a
210
+ * query carried it into `config.queries` and applied it nowhere, so a query that said `filter:`
211
+ * delivered every record with nothing said (measured 2026-10-06).
212
+ *
213
+ * @param {Object|undefined} declaration - a query declaration or a binding
214
+ * @param {string} context - where it sits, for the message
215
+ */
216
+ export function refuseFilter(declaration, context) {
217
+ if (!declaration || typeof declaration !== 'object' || declaration.filter === undefined) return
218
+ throw new Error(
219
+ `[uniweb] ${context}: \`filter:\` is retired. Write the predicate as \`where:\`, a where-object — ` +
220
+ `e.g. \`where: { tags: { contains: featured } }\`.`
221
+ )
222
+ }
223
+
206
224
  /**
207
225
  * ⛔ A `where` OUTSIDE THE LANGUAGE STOPS THE BUILD — a retired operator (`like`,
208
226
  * `nin`), an unknown one, an empty `and` / `or`, a text operator with an empty
@@ -340,6 +358,7 @@ export function refuseBinding(fetch, context, { level = null } = {}) {
340
358
  if (typeof fetch !== 'object' || Array.isArray(fetch)) return
341
359
  refuseRefineAndMisplacedCurrent(fetch, context, level)
342
360
  refuseMerge(fetch, context)
361
+ refuseFilter(fetch, context)
343
362
  refuseSourceKeys(fetch, context)
344
363
  if (fetch.collection === undefined) refuseQueryName(fetch.query, context, { object: true })
345
364
  refuseUnder(fetch.where, context)
@@ -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
package/src/site/index.js CHANGED
@@ -41,8 +41,7 @@ export {
41
41
  } from './advanced-processors.js'
42
42
  export {
43
43
  processQueries,
44
- writeQueryFiles,
45
- getQueryLastModified
44
+ writeQueryFiles
46
45
  } from './query-processor.js'
47
46
  export { collectSchemalessData, collectSchemalessDataAssets, rewriteSchemalessDataAssets } from './schemaless-data.js'
48
47
  export {
@@ -655,6 +655,18 @@ export function siteContentPlugin(options = {}) {
655
655
  }
656
656
 
657
657
  let siteContent = null
658
+ // Where `uniweb dev` answers the site's `backend` service with `$devBackend`'s handler — set
659
+ // in `configureServer`, so never in a build.
660
+ let devApiAddress = null
661
+ /**
662
+ * The dev payload names `$devBackend`'s address as the site's `backend` service: the site
663
+ * tier, which `resolveService` reads when no host speaks. A build never gets here
664
+ * with one.
665
+ */
666
+ const withDevApi = (content) => {
667
+ if (devApiAddress && content?.config) content.config.backend = devApiAddress
668
+ return content
669
+ }
658
670
  // What each section type declares its data blocks are — read once per collection of the site.
659
671
  let dataModelPromise = null
660
672
  const dataModels = () => (dataModelPromise ||= dataBlockModels(resolvedSitePath).catch(() => NO_DATA_MODELS))
@@ -955,7 +967,7 @@ export function siteContentPlugin(options = {}) {
955
967
  // pages stay in the graph so in-progress drafts remain previewable.
956
968
  // strict on a production build: a mount that contributes no pages is a
957
969
  // warning while you author and a broken deploy once you ship.
958
- siteContent = await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction })
970
+ siteContent = withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, dropUnpublished: isProduction, base: basePath, strict: isProduction }))
959
971
  dataModelPromise = null
960
972
  headHtml = await loadHeadHtml()
961
973
  console.log(`[site-content] Collected ${siteContent.pages?.length || 0} pages`)
@@ -997,14 +1009,15 @@ export function siteContentPlugin(options = {}) {
997
1009
  configureServer(devServer) {
998
1010
  server = devServer
999
1011
 
1000
- // A site's own backend, answered locally in development. `site.yml::$devApi`
1001
- // names a module that default-exports a fetch handler, mounted at the site's
1002
- // own `api:` address so the address is identical in dev and in production.
1012
+ // A site's own backend, answered locally in development. `site.yml::$devBackend`
1013
+ // names a module that default-exports a fetch handler; the dev server mounts it
1014
+ // at an address of its own and names that address in the payload it serves
1015
+ // (`withDevApi`), so the site's `backend` service resolves to it.
1003
1016
  // ⚠️ Synchronous on purpose — see mountDevApi. An await here and the
1004
1017
  // middleware lands after Vite's SPA fallback, which answers the API with
1005
1018
  // index.html and says nothing about why.
1006
1019
  try {
1007
- mountDevApi(devServer, { root: resolvedSitePath })
1020
+ devApiAddress = mountDevApi(devServer, { root: resolvedSitePath })
1008
1021
  } catch (err) {
1009
1022
  console.error(`[dev-api] ${err.message}`)
1010
1023
  }
@@ -1025,7 +1038,7 @@ export function siteContentPlugin(options = {}) {
1025
1038
  rebuildQueries = false
1026
1039
  console.log('[site-content] Content changed, rebuilding...')
1027
1040
  try {
1028
- siteContent = await collectForBundle(resolvedSitePath, { foundationPath, base: basePath })
1041
+ siteContent = withDevApi(await collectForBundle(resolvedSitePath, { foundationPath, base: basePath }))
1029
1042
  dataModelPromise = null
1030
1043
  headHtml = await loadHeadHtml()
1031
1044
  if (materialize) {
@@ -37,7 +37,7 @@ import { readRegisteredFoundation } from './registered-foundation.js'
37
37
  import { buildDataSchemaMap, SCHEMA_NOT_FOUND } from '../resolve-data-schema.js'
38
38
  import { resolveFoundationSrcPath } from '../utils/foundation-source-root.js'
39
39
  import { buildSchema } from '../schema.js'
40
- import { refuseQueryRoute, refuseLimit } from './data-fetcher.js'
40
+ 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'
@@ -248,6 +248,7 @@ export function refuseQueryDeclaration(decl) {
248
248
  }
249
249
  refuseQueryRoute(decl, where)
250
250
  refuseLimit(decl.limit, where)
251
+ refuseFilter(decl, where)
251
252
  // ⛔ `sync:` IS RETIRED (2026-09-21). `sync: false` kept a query's records off a
252
253
  // backend while a query owned its records; every record in `records/` is pushed
253
254
  // now, whatever reads it, so an ignored `sync: false` would push exactly what the