@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 +8 -8
- package/src/content/index.js +0 -1
- package/src/dev/api-mount.js +30 -20
- package/src/foundation/derive-supports.js +3 -2
- package/src/resolve-data-schema.js +2 -1
- package/src/runtime-schema.js +30 -12
- package/src/schema.js +93 -1
- package/src/site/content-collector.js +21 -6
- package/src/site/data-fetcher.js +19 -0
- package/src/site/deploy-config-writer.js +4 -2
- package/src/site/index.js +1 -2
- package/src/site/plugin.js +19 -6
- package/src/site/queries-config.js +2 -1
- package/src/site/query-processor.js +6 -42
- package/src/uwx/emit-surface.json +1 -13
- package/src/uwx/index.js +12 -5
- package/src/uwx/project-writer.js +31 -21
- package/src/uwx/services-request.js +471 -198
- package/src/uwx/shown-records.js +52 -0
- package/src/uwx/site-diff.js +47 -0
- package/src/uwx/site-project.js +40 -55
- package/src/uwx/site.js +61 -162
- package/src/uwx/sync-package.js +30 -21
- package/src/uwx/yaml-edit.js +288 -0
- package/src/validate-data.js +18 -11
- package/src/uwx/yaml-upsert.js +0 -79
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "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/
|
|
62
|
-
"@uniweb/schemas": "^0.12.0",
|
|
63
|
+
"@uniweb/schemas": "^0.13.0",
|
|
63
64
|
"@uniweb/content-reader": "^1.2.8",
|
|
64
|
-
"@uniweb/
|
|
65
|
-
"@uniweb/content-writer": "^0.3.4"
|
|
65
|
+
"@uniweb/theming": "^0.2.0"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.29.
|
|
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/
|
|
78
|
-
"@uniweb/
|
|
77
|
+
"@uniweb/runtime": "^0.29.3",
|
|
78
|
+
"@uniweb/core": "^0.38.0"
|
|
79
79
|
},
|
|
80
80
|
"peerDependenciesMeta": {
|
|
81
81
|
"vite": {
|
package/src/content/index.js
CHANGED
|
@@ -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.
|
package/src/dev/api-mount.js
CHANGED
|
@@ -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
|
|
13
|
-
* dev
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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
|
-
* `
|
|
39
|
-
* `info` key carries it, and
|
|
40
|
-
*
|
|
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 {
|
|
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
|
|
89
|
+
return null
|
|
74
90
|
}
|
|
75
91
|
|
|
76
|
-
const spec = site.$
|
|
77
|
-
if (!spec) return
|
|
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
|
|
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 `
|
|
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
|
-
// `
|
|
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
|
}
|
package/src/runtime-schema.js
CHANGED
|
@@ -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`
|
|
9
|
-
* declares, in order, with its schema ref (null for
|
|
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
|
|
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
|
-
*
|
|
43
|
-
*
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
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] =
|
|
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
|
|
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] =
|
|
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
|
-
*
|
|
2452
|
-
*
|
|
2453
|
-
* declines every service it does not name
|
|
2454
|
-
*
|
|
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
|
|
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
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
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 {
|
package/src/site/plugin.js
CHANGED
|
@@ -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::$
|
|
1001
|
-
// names a module that default-exports a fetch handler
|
|
1002
|
-
//
|
|
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
|