@uniweb/build 0.49.0 → 0.50.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 +7 -7
- package/src/generate-entry.js +2 -1
- package/src/prerender.js +15 -12
- package/src/runtime-schema.js +29 -41
- package/src/schema.js +2 -0
- package/src/site/content-collector.js +22 -19
- package/src/site/data-fetcher.js +37 -36
- package/src/site/queries-config.js +10 -2
- package/src/site/query-processor.js +15 -39
- package/src/uwx/site.js +7 -7
- package/src/validate-data.js +51 -46
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.50.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -58,14 +58,14 @@
|
|
|
58
58
|
"sharp": "^0.35.3",
|
|
59
59
|
"yaml": "^2.5.0",
|
|
60
60
|
"@uniweb/content-reader": "^1.2.4",
|
|
61
|
-
"@uniweb/content-writer": "^0.3.4",
|
|
62
|
-
"@uniweb/semantic-parser": "^1.4.0",
|
|
63
|
-
"@uniweb/projections": "^0.7.1",
|
|
64
61
|
"@uniweb/schemas": "^0.2.13",
|
|
65
|
-
"@uniweb/
|
|
62
|
+
"@uniweb/semantic-parser": "^1.4.0",
|
|
63
|
+
"@uniweb/theming": "^0.1.15",
|
|
64
|
+
"@uniweb/projections": "^0.7.2",
|
|
65
|
+
"@uniweb/content-writer": "^0.3.4"
|
|
66
66
|
},
|
|
67
67
|
"optionalDependencies": {
|
|
68
|
-
"@uniweb/runtime": "^0.
|
|
68
|
+
"@uniweb/runtime": "^0.25.0"
|
|
69
69
|
},
|
|
70
70
|
"peerDependencies": {
|
|
71
71
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
75
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
76
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/core": "^0.
|
|
77
|
+
"@uniweb/core": "^0.29.0"
|
|
78
78
|
},
|
|
79
79
|
"peerDependenciesMeta": {
|
|
80
80
|
"vite": {
|
package/src/generate-entry.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The `meta` export contains only properties needed at runtime:
|
|
12
12
|
* - `background` - 'self' opt-out when component handles its own background
|
|
13
|
-
* - `data` -
|
|
13
|
+
* - `data` - the `content.data` keys the component declares, with their schema refs
|
|
14
|
+
* - `schemas` - field defaults for those keys
|
|
14
15
|
* - `defaults` - Param default values
|
|
15
16
|
* - `context` - Static capabilities for cross-block coordination
|
|
16
17
|
* - `initialState` - Initial values for mutable block state
|
package/src/prerender.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
routeBinding,
|
|
23
23
|
parentRouteOf,
|
|
24
24
|
deriveCacheKey,
|
|
25
|
+
evaluateQuery,
|
|
25
26
|
} from '@uniweb/core'
|
|
26
27
|
import { routePatternToRegex } from '@uniweb/core/route-match'
|
|
27
28
|
import { executeFetch, mergeDataIntoContent, toFetchList, stripBuildOnlyFetchKeys } from './site/data-fetcher.js'
|
|
@@ -131,21 +132,22 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
131
132
|
const fetched = { site: new Map(), pages: new Map(), sections: new Map() }
|
|
132
133
|
|
|
133
134
|
/**
|
|
134
|
-
* Read one resolved config ONCE, as two things:
|
|
135
|
-
*
|
|
136
|
-
* `
|
|
137
|
-
* a parametric page expands over, because a
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
135
|
+
* Read one resolved config ONCE, as two things: its query's SET (`routeSelection` —
|
|
136
|
+
* the query as saved, its `limit` included), and the view it delivers — that set
|
|
137
|
+
* with the fetch's `narrow` applied (`evaluateQuery`). ⭐ The view is what a page
|
|
138
|
+
* renders and hydrates; the set is what a parametric page expands over, because a
|
|
139
|
+
* parametric page has a URL for each record of its route query's set and no other —
|
|
140
|
+
* a fetch's narrowing never decides which pages exist (ruled 2026-09-14 [Diego]).
|
|
141
|
+
*
|
|
142
|
+
* ⛔ Until 2026-09-13 the expansion read the list a `limit` cut, so a list's
|
|
143
|
+
* `limit: 2` made two detail pages of five; from then until 2026-09-14 it read the
|
|
144
|
+
* records with no `limit` at all, the query's included. Null when the read failed.
|
|
141
145
|
*/
|
|
142
146
|
const read = async (cfg, oneFetch) => {
|
|
143
147
|
const result = await executeFetch(routeSelection(cfg), optionsFor(cfg, oneFetch))
|
|
144
148
|
if (!result.data || result.error) return null
|
|
145
149
|
const selection = result.data
|
|
146
|
-
const view =
|
|
147
|
-
? selection.slice(0, cfg.limit)
|
|
148
|
-
: selection
|
|
150
|
+
const view = cfg.narrow ? evaluateQuery(selection, { narrow: cfg.narrow }, { locale }) : selection
|
|
149
151
|
return { selection, view }
|
|
150
152
|
}
|
|
151
153
|
|
|
@@ -251,8 +253,9 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
|
|
|
251
253
|
* @param {Array} pages - Original pages array
|
|
252
254
|
* @param {{ site?: Map, pages?: Map, sections?: Map }} fetched - what each level
|
|
253
255
|
* selected, by binding key (`executeAllFetches`): `site` key → data; `pages` and
|
|
254
|
-
* `sections` route → (key → data).
|
|
255
|
-
* `limit`
|
|
256
|
+
* `sections` route → (key → data). Each binding's query SET — the query as saved,
|
|
257
|
+
* its `limit` included, without the fetch's narrowing: a record past a list's count
|
|
258
|
+
* still gets its page, and one past the query's gets none
|
|
256
259
|
* @param {function} onProgress - Progress callback
|
|
257
260
|
* @param {Object} [stats] - receives `unrouted[route]`, the records with no param value
|
|
258
261
|
* @param {Object} [options]
|
package/src/runtime-schema.js
CHANGED
|
@@ -6,22 +6,19 @@
|
|
|
6
6
|
* needed at render time:
|
|
7
7
|
*
|
|
8
8
|
* - background: 'self' when component handles its own background
|
|
9
|
-
* - data: {
|
|
9
|
+
* - data: { <key>: <schema ref> | null } — every `content.data` key the component
|
|
10
|
+
* declares, in order, with its schema ref (null for an inline shape)
|
|
11
|
+
* - schemas: { <key>: <lean fields> } — field defaults for the declared keys that have any
|
|
10
12
|
* - defaults: param default values
|
|
11
13
|
* - context: static capabilities for cross-block coordination
|
|
12
14
|
* - initialState: initial values for mutable block state
|
|
13
|
-
* - inheritData: internal flag for cascaded data delivery
|
|
14
|
-
* true → deliver all data available at ancestor levels (default)
|
|
15
|
-
* false → deliver nothing (component opted out with `data: false`)
|
|
16
15
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* tells the editor and prepare-props what shape the component expects,
|
|
24
|
-
* but does not restrict delivery.
|
|
16
|
+
* ⭐ `data:` IS THE DELIVERY — ruled 2026-09-14 [Diego]: a section's `content.data` holds
|
|
17
|
+
* the keys its component declares and nothing else, and which fetch fills each is worked
|
|
18
|
+
* out at render from the keys and their schema refs (`@uniweb/core` `fillDeclaredKeys`).
|
|
19
|
+
* A component with no `data:`, or `data: false`, receives none of its own. ⛔ Until then
|
|
20
|
+
* delivery was default-on — every key that reached a section — and `data:` was a hint for
|
|
21
|
+
* defaults and the editor; `data: false` was the opt-out, emitted as `inheritData: false`.
|
|
25
22
|
*
|
|
26
23
|
* Full metadata (titles, descriptions, hints, etc.) stays in schema.json
|
|
27
24
|
* for the visual editor.
|
|
@@ -31,23 +28,15 @@ import { isRichSchema } from '@uniweb/core'
|
|
|
31
28
|
import { flatRecordFields } from '@uniweb/schemas/conform'
|
|
32
29
|
|
|
33
30
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* 'events:6' -> { type: 'events', limit: 6 }
|
|
31
|
+
* A `data:` entry's schema ref — a ref string, or `{ schema }` — or null for an inline
|
|
32
|
+
* shape, which names no schema and is filled only under its own key.
|
|
37
33
|
*
|
|
38
|
-
* @param {string}
|
|
39
|
-
* @returns {
|
|
34
|
+
* @param {string|Object} value
|
|
35
|
+
* @returns {string|null}
|
|
40
36
|
*/
|
|
41
|
-
function
|
|
42
|
-
if (
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const [type, limitStr] = dataString.split(':')
|
|
47
|
-
return {
|
|
48
|
-
type: type.trim(),
|
|
49
|
-
limit: limitStr ? parseInt(limitStr, 10) : null,
|
|
50
|
-
}
|
|
37
|
+
function dataRef(value) {
|
|
38
|
+
if (typeof value === 'string') return value || null
|
|
39
|
+
return value && typeof value === 'object' && typeof value.schema === 'string' && value.schema ? value.schema : null
|
|
51
40
|
}
|
|
52
41
|
|
|
53
42
|
/**
|
|
@@ -284,18 +273,22 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
|
|
|
284
273
|
runtime.background = fullMeta.background
|
|
285
274
|
}
|
|
286
275
|
|
|
287
|
-
// Data
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
276
|
+
// Data. `data:` is the single declaration surface for a section's structured
|
|
277
|
+
// data: it maps each `content.data` key to its schema. A value is a named ref
|
|
278
|
+
// (`'@/member'`), an inline field map, or an inline rich-form (`{ fields: [...] }`,
|
|
279
|
+
// an editor form). Source-agnostic — the data may arrive by fetch, tagged code
|
|
280
|
+
// block, or editor form; the schema and its defaults are identical.
|
|
281
|
+
//
|
|
282
|
+
// ⭐ Every key reaches the runtime, a key with no fields included — `data` lists them
|
|
283
|
+
// with their refs, because the keys ARE what the section receives, and a key's ref is
|
|
284
|
+
// what a fetch of another name fills it by. `schemas` carries field defaults for the
|
|
285
|
+
// keys that have fields. `data: false` declares nothing, as no `data:` does.
|
|
295
286
|
if (fullMeta.data === false) {
|
|
296
|
-
|
|
287
|
+
// declares no key
|
|
297
288
|
} else if (fullMeta.data && typeof fullMeta.data === 'object' && !Array.isArray(fullMeta.data)) {
|
|
298
289
|
for (const [key, value] of Object.entries(fullMeta.data)) {
|
|
290
|
+
runtime.data = runtime.data || {}
|
|
291
|
+
runtime.data[key] = dataRef(value)
|
|
299
292
|
const lean = leanDataSchema(value, dataSchemaMap)
|
|
300
293
|
if (lean) {
|
|
301
294
|
runtime.schemas = runtime.schemas || {}
|
|
@@ -330,11 +323,6 @@ export function extractRuntimeSchema(fullMeta, dataSchemaMap = {}) {
|
|
|
330
323
|
// (Top-level `schemas:` is gone — inline field maps and rich-forms are now
|
|
331
324
|
// just `data:` entries with an inline value. See leanDataSchema.)
|
|
332
325
|
|
|
333
|
-
// Data delivery is default-on. `runtime.inheritData` stays undefined unless
|
|
334
|
-
// the component opts out with `data: false`, in which case EntityStore
|
|
335
|
-
// delivers nothing. A `data:` binding is a hint (schema for defaults +
|
|
336
|
-
// editor), never a delivery gate.
|
|
337
|
-
|
|
338
326
|
return Object.keys(runtime).length > 0 ? runtime : null
|
|
339
327
|
}
|
|
340
328
|
|
package/src/schema.js
CHANGED
|
@@ -35,7 +35,7 @@ import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
|
|
|
35
35
|
import { parseFetchConfig, toFetchList } from './data-fetcher.js'
|
|
36
36
|
import { resolveExtensionUrls } from './extension-urls.js'
|
|
37
37
|
import { buildTheme, extractFoundationVars } from '../theme/index.js'
|
|
38
|
-
import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig, pageRouteQuery } from '@uniweb/core'
|
|
38
|
+
import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig, pageRouteQuery, currentFor } from '@uniweb/core'
|
|
39
39
|
import { parseFrontmatter } from '../utils/frontmatter.js'
|
|
40
40
|
|
|
41
41
|
// Try to import content-reader, fall back to simplified parser
|
|
@@ -306,10 +306,14 @@ const formatQueryNames = (value) =>
|
|
|
306
306
|
isQueryNames(value) ? (Array.isArray(value) ? `[${value.join(', ')}]` : value) : '<name>'
|
|
307
307
|
|
|
308
308
|
/**
|
|
309
|
-
* ⚠️ A `current:` NOTHING WILL READ — said once per binding. `current:`
|
|
310
|
-
* section's
|
|
311
|
-
* `pageRouteQuery`)
|
|
312
|
-
*
|
|
309
|
+
* ⚠️ A `current:` NOTHING WILL READ — said once per binding. `current:` follows the query a
|
|
310
|
+
* section's fetch names (`currentFor`, `@uniweb/core`, ruled 2026-09-14): on a page with a
|
|
311
|
+
* route query (`pageRouteQuery`) it is read on a fetch of that query or of any other, so it
|
|
312
|
+
* goes unread on a page with none — a static page, a parametric page whose sections name
|
|
313
|
+
* different queries with nothing above them, a layout area. There the runtime ignores it,
|
|
314
|
+
* and the author would see the section deliver its list as though the line were not there.
|
|
315
|
+
* ⛔ Until 2026-09-14 this warned by key, as the runtime then read it: on any key but the
|
|
316
|
+
* route query's.
|
|
313
317
|
*
|
|
314
318
|
* @param {Array<Object>} pages - collected pages, parents linked
|
|
315
319
|
* @param {Object} layouts - collected layout areas (never on a parametric route)
|
|
@@ -324,24 +328,24 @@ function warnUnreadCurrent(pages, layouts, siteFetch) {
|
|
|
324
328
|
sectionsOf: (p) => p.sections,
|
|
325
329
|
site: siteFetch,
|
|
326
330
|
}
|
|
327
|
-
const check = (sections, where,
|
|
331
|
+
const check = (sections, where, routeOf) => {
|
|
328
332
|
for (const section of sections || []) {
|
|
329
333
|
for (const one of toFetchList(section.fetch)) {
|
|
330
334
|
if (one?.current === undefined) continue
|
|
331
|
-
const
|
|
332
|
-
if (one
|
|
335
|
+
const route = routeOf()
|
|
336
|
+
if (currentFor(one, route) !== null) continue
|
|
333
337
|
console.warn(
|
|
334
338
|
`[uniweb] ${where}: \`current: ${one.current}\` on content.data.${one.as} is ignored — it applies on a ` +
|
|
335
|
-
`section of a parametric page,
|
|
336
|
-
(
|
|
339
|
+
`section of a parametric page, to a fetch that names a query` +
|
|
340
|
+
(route ? ` (this page's URL names one of \`${route.key}\`).` : '; this page has none.')
|
|
337
341
|
)
|
|
338
342
|
}
|
|
339
|
-
check(section.subsections, where,
|
|
343
|
+
check(section.subsections, where, routeOf)
|
|
340
344
|
}
|
|
341
345
|
}
|
|
342
346
|
for (const page of pages) {
|
|
343
|
-
let
|
|
344
|
-
check(page.sections, page.route, () => (
|
|
347
|
+
let route
|
|
348
|
+
check(page.sections, page.route, () => (route === undefined ? (route = pageRouteQuery(page, access) ?? null) : route))
|
|
345
349
|
}
|
|
346
350
|
for (const [name, areas] of Object.entries(layouts || {})) {
|
|
347
351
|
for (const [area, areaPage] of Object.entries(areas || {})) {
|
|
@@ -1054,12 +1058,11 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
1054
1058
|
// Extract @ component references → insets (mutates doc)
|
|
1055
1059
|
const insets = extractInsets(proseMirrorContent)
|
|
1056
1060
|
|
|
1057
|
-
// `query: team` → `fetch: { query: team }`; a list, one config per name, each
|
|
1058
|
-
//
|
|
1059
|
-
//
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1062
|
-
// not it names one here.
|
|
1061
|
+
// `query: team` → `fetch: { query: team }`; a list, one config per name, each under
|
|
1062
|
+
// its own `as` — the one helper every level uses. Unrelated to a section type's
|
|
1063
|
+
// `meta.js` `data:`, which declares the `content.data` keys a component receives and
|
|
1064
|
+
// fetches nothing: which fetch fills each is worked out at render (`fillDeclaredKeys`,
|
|
1065
|
+
// `@uniweb/core`, 2026-09-14).
|
|
1063
1066
|
const resolvedFetch = declaredFetch({ fetch, query, data }, relative(siteRoot, filePath))
|
|
1064
1067
|
|
|
1065
1068
|
// Stable ID for scroll targeting: frontmatter id > filename-derived > null
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -19,7 +19,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
19
19
|
import { join } from 'node:path'
|
|
20
20
|
import { existsSync } from 'node:fs'
|
|
21
21
|
import yaml from 'js-yaml'
|
|
22
|
-
import { matchWhere, sortRecords, queryDataUrl,
|
|
22
|
+
import { matchWhere, sortRecords, queryDataUrl, evaluateQuery, whereOutsideLanguage, CURRENT_MODES } from '@uniweb/core'
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Get a nested value from an object using dot notation
|
|
@@ -83,47 +83,25 @@ export function applyWhere(items, where) {
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
|
-
* Apply post-processing to fetched data
|
|
86
|
+
* Apply post-processing to fetched data — a resolved config's two levels, by
|
|
87
|
+
* `@uniweb/core`'s `evaluateQuery`, the one order of work the runtime's default
|
|
88
|
+
* fetcher uses too:
|
|
87
89
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* 2. sort
|
|
91
|
-
*
|
|
90
|
+
* 1. the query's set — `scope` over each record's placement (`path`), `where`,
|
|
91
|
+
* `sort`, `limit`;
|
|
92
|
+
* 2. the fetch's `narrow` of it — `where` (and `match`), `sort`, `limit`.
|
|
93
|
+
*
|
|
94
|
+
* A bad `sort:` throws here: an authoring error stops the build.
|
|
92
95
|
*
|
|
93
96
|
* @param {any} data - Fetched data
|
|
94
|
-
* @param {object} config -
|
|
97
|
+
* @param {object} config - A resolved fetch config
|
|
95
98
|
* @param {object} [options]
|
|
96
99
|
* @param {string|null} [options.locale] - the page's locale, which texts sort in
|
|
97
100
|
* @returns {any} Processed data
|
|
98
101
|
*/
|
|
99
102
|
export function applyPostProcessing(data, config, { locale = null } = {}) {
|
|
100
|
-
if (!data || !Array.isArray(data)) return data
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
let result = data
|
|
104
|
-
|
|
105
|
-
// `scope` first — the folder branch the rest of the query reads, over each
|
|
106
|
-
// record's placement (`path`), as the runtime's default fetcher applies it.
|
|
107
|
-
if (typeof config.scope === 'string' && config.scope) {
|
|
108
|
-
result = applyScope(result, config.scope)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Apply where-object predicate first (new path)
|
|
112
|
-
if (config.where) {
|
|
113
|
-
result = applyWhere(result, config.where)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Apply sort
|
|
117
|
-
if (config.sort) {
|
|
118
|
-
result = applySort(result, config.sort, { locale })
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Apply limit last
|
|
122
|
-
if (config.limit && config.limit > 0) {
|
|
123
|
-
result = result.slice(0, config.limit)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return result
|
|
103
|
+
if (!data || !Array.isArray(data) || !config) return data
|
|
104
|
+
return evaluateQuery(data, config, { locale })
|
|
127
105
|
}
|
|
128
106
|
|
|
129
107
|
/**
|
|
@@ -172,6 +150,27 @@ const RECOGNIZED_FETCH_KEYS = {
|
|
|
172
150
|
]),
|
|
173
151
|
}
|
|
174
152
|
|
|
153
|
+
/**
|
|
154
|
+
* ⛔ `route:` ON A QUERY IS RETIRED (2026-09-14 [Diego]) — refused, because a query
|
|
155
|
+
* that still declares it would build and its cards would lose their links silently. A
|
|
156
|
+
* record links to the page whose route query is its query, as `$route`, which the
|
|
157
|
+
* runtime fills at render time on every lane; a fetch picks another page with
|
|
158
|
+
* `detailPage:`. The build baked `route:` into each compiled record as `route`, a field
|
|
159
|
+
* in the author's namespace that it overwrote.
|
|
160
|
+
*
|
|
161
|
+
* @param {Object} decl - a query declaration
|
|
162
|
+
* @param {string} context - where the declaration sits, for the message
|
|
163
|
+
*/
|
|
164
|
+
export function refuseQueryRoute(decl, context) {
|
|
165
|
+
if (!decl || typeof decl !== 'object' || decl.route === undefined) return
|
|
166
|
+
throw new Error(
|
|
167
|
+
`[uniweb] ${context}: \`route:\` is retired. Each record links to the page that shows one record of this ` +
|
|
168
|
+
`query — the \`[slug]\` page whose URL names one of its records — as \`$route\`, with nothing to declare: ` +
|
|
169
|
+
`delete the line, and read \`record.$route\` in a component. To link to another page, add ` +
|
|
170
|
+
`\`detailPage: page:<id>\` to the fetch.`
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
175
174
|
/**
|
|
176
175
|
* ⛔ `under` IS RETIRED (2026-09-11 [Diego]) — refused, like every retired spelling
|
|
177
176
|
* here, because an ignored predicate is a silently wrong answer. It existed for
|
|
@@ -551,8 +550,10 @@ export function parseFetchConfig(fetch, context = 'fetch', { level = null } = {}
|
|
|
551
550
|
// (`@uniweb/core/fetch-config`) — and the parser does not know which this is.
|
|
552
551
|
prerender: fetch.prerender,
|
|
553
552
|
merge: fetch.merge ?? false,
|
|
554
|
-
// A binding's adaptations of its query
|
|
555
|
-
//
|
|
553
|
+
// A binding's adaptations of its query, kept as authored: the resolver makes them
|
|
554
|
+
// the fetch's `narrow` of the query's set (`@uniweb/core/fetch-config`,
|
|
555
|
+
// `setAndNarrow`) — its `where` filters the set, its `sort` re-orders it, its
|
|
556
|
+
// `limit` cuts it and never reaches past it.
|
|
556
557
|
where: fetch.where,
|
|
557
558
|
limit: fetch.limit,
|
|
558
559
|
sort: fetch.sort,
|
|
@@ -32,6 +32,7 @@ import { join } from 'node:path'
|
|
|
32
32
|
import { existsSync, readFileSync } from 'node:fs'
|
|
33
33
|
import { briefFields, flatRecordFields } from '@uniweb/schemas/conform'
|
|
34
34
|
import { detectFoundationType } from './foundation-ref.js'
|
|
35
|
+
import { refuseQueryRoute } from './data-fetcher.js'
|
|
35
36
|
import { readFile } from 'node:fs/promises'
|
|
36
37
|
import yaml from 'js-yaml'
|
|
37
38
|
|
|
@@ -189,7 +190,7 @@ export async function resolveQueriesConfig(siteRoot, opts = {}) {
|
|
|
189
190
|
}
|
|
190
191
|
|
|
191
192
|
/** What a query over the site's records declares, which an external query cannot. */
|
|
192
|
-
const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'deferred', 'excerpt', '
|
|
193
|
+
const SITE_RECORDS_ONLY = ['schema', 'model', 'scope', 'deferred', 'excerpt', 'path']
|
|
193
194
|
/** The keys of an external query's `record:` request. */
|
|
194
195
|
const RECORD_KEYS = ['url', 'method', 'body', 'transform']
|
|
195
196
|
|
|
@@ -202,9 +203,15 @@ const RECORD_KEYS = ['url', 'method', 'body', 'transform']
|
|
|
202
203
|
* `transform` (a dot-path to its records), `where` / `sort` / `limit` evaluated over
|
|
203
204
|
* them, and `record:` — `{ url, method, body, transform }` — for one record on a
|
|
204
205
|
* parametric page. What describes the site's records — `schema`, `scope`,
|
|
205
|
-
* `deferred`, `excerpt
|
|
206
|
+
* `deferred`, `excerpt` — is refused beside `url:`. ⛔ `detailUrl:` is
|
|
206
207
|
* retired everywhere: its one real case is `record.url`.
|
|
207
208
|
*
|
|
209
|
+
* ⛔ `route:` is retired everywhere too (2026-09-14 [Diego]): a record links to the page
|
|
210
|
+
* whose route query is its query, as `$route`, which the runtime fills at render time
|
|
211
|
+
* on every lane — and a fetch that wants another page names it with `detailPage:`. The
|
|
212
|
+
* build baked `route:` into each compiled record as `route`, a field in the author's
|
|
213
|
+
* namespace that it overwrote.
|
|
214
|
+
*
|
|
208
215
|
* @param {Object} decl - one normalized declaration (`{ name, … }`)
|
|
209
216
|
*/
|
|
210
217
|
export function refuseQueryDeclaration(decl) {
|
|
@@ -215,6 +222,7 @@ export function refuseQueryDeclaration(decl) {
|
|
|
215
222
|
`on an external query — one with \`url:\`.`
|
|
216
223
|
)
|
|
217
224
|
}
|
|
225
|
+
refuseQueryRoute(decl, where)
|
|
218
226
|
const external = decl.url !== undefined
|
|
219
227
|
if (!external) {
|
|
220
228
|
for (const key of ['method', 'body', 'transform', 'record']) {
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
* merge — the loader flattens one level after collecting them.
|
|
35
35
|
* - Converts markdown body to ProseMirror JSON
|
|
36
36
|
* - Applies the query's fixed `where` and orders by its `sort`; its `limit` is
|
|
37
|
-
* left to the runtime, so every record the query
|
|
37
|
+
* left to the runtime, which makes each page's set, so every record the query's
|
|
38
|
+
* fixed `where` selects compiles
|
|
38
39
|
* - Auto-generates excerpts and extracts first images (markdown items only)
|
|
39
40
|
*
|
|
40
41
|
* @module @uniweb/build/site/collection-processor
|
|
@@ -55,8 +56,8 @@ import { join, basename, extname, dirname, relative, resolve, sep } from 'node:p
|
|
|
55
56
|
import { existsSync } from 'node:fs'
|
|
56
57
|
import yaml from 'js-yaml'
|
|
57
58
|
import { parseBibtex } from '@citestyle/bibtex'
|
|
58
|
-
import { DATA_DIR,
|
|
59
|
-
import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage } from './data-fetcher.js'
|
|
59
|
+
import { DATA_DIR, withoutRouteVariables } from '@uniweb/core'
|
|
60
|
+
import { applyWhere, applySort, refuseUnder, refuseOutsideLanguage, refuseQueryRoute } from './data-fetcher.js'
|
|
60
61
|
import { resolveAssetPath, walkContentAssets, isLocalAssetPath } from './assets.js'
|
|
61
62
|
import { readEntityPool, groupPoolBySchema, ENTITIES_DIR } from './entity-pool.js'
|
|
62
63
|
import { readRecordsConfig, resolveFolder, FOLDER_MISSING } from './records-config.js'
|
|
@@ -93,8 +94,7 @@ try {
|
|
|
93
94
|
*
|
|
94
95
|
* // Extended form
|
|
95
96
|
* parseQueryConfig('articles', {
|
|
96
|
-
*
|
|
97
|
-
* route: '/blog',
|
|
97
|
+
* schema: '@/article',
|
|
98
98
|
* sort: 'date desc',
|
|
99
99
|
* filter: 'published != false',
|
|
100
100
|
* limit: 100
|
|
@@ -113,7 +113,6 @@ function parseQueryConfig(name, config) {
|
|
|
113
113
|
name,
|
|
114
114
|
schema: config,
|
|
115
115
|
url: null,
|
|
116
|
-
route: null,
|
|
117
116
|
scope: null,
|
|
118
117
|
sort: null,
|
|
119
118
|
where: null,
|
|
@@ -126,13 +125,13 @@ function parseQueryConfig(name, config) {
|
|
|
126
125
|
|
|
127
126
|
refuseUnder(config.where, `queries.${name}`)
|
|
128
127
|
refuseOutsideLanguage(config.where, `queries.${name}`)
|
|
128
|
+
refuseQueryRoute(config, `queries.${name}`)
|
|
129
129
|
return {
|
|
130
130
|
name,
|
|
131
131
|
// The query's schema selects its records from the pool — `entities/{schema}/`
|
|
132
132
|
// declares the model, so the entities of a schema ARE the query's records.
|
|
133
133
|
schema: config.schema || null,
|
|
134
134
|
url: config.url || null,
|
|
135
|
-
route: config.route || null,
|
|
136
135
|
// The folder branch the query reads (`records.yml` placement). ⛔ Not read
|
|
137
136
|
// here until 2026-09-11: a named query's `scope` was ignored on this lane.
|
|
138
137
|
scope: typeof config.scope === 'string' ? config.scope : null,
|
|
@@ -686,32 +685,6 @@ async function collectItems(siteDir, config, entitiesDir, basePath, locale = nul
|
|
|
686
685
|
|
|
687
686
|
warnDuplicateSlugs(items, config.name)
|
|
688
687
|
|
|
689
|
-
// `route:` on the query — bake each record's canonical href.
|
|
690
|
-
//
|
|
691
|
-
// ⭐ THROUGH THE ONE ENCODER (`fillRoutePattern`, `@uniweb/core/route-match`),
|
|
692
|
-
// which is what the runtime's `addDetailRoute` also calls. Until 2026-09-04 this
|
|
693
|
-
// interpolated `${baseRoute}/${item.slug}` RAW while the runtime encoded, and a
|
|
694
|
-
// record already carrying a baked route keeps it — so the same record got two
|
|
695
|
-
// different hrefs depending on which lane served it (F14): a slug with a space
|
|
696
|
-
// compared unequal to `location.pathname`, and a slug with a `/` became an
|
|
697
|
-
// extra route segment. A record with no slug gets no route rather than
|
|
698
|
-
// `/blog/undefined`.
|
|
699
|
-
//
|
|
700
|
-
// `route: /blog` names the base of a `[slug]` page, so the template is
|
|
701
|
-
// `/blog/:slug`. `route: /blog/[...path]` names a `[...path]` page: the
|
|
702
|
-
// template is `/blog/:path*` and the record's placement (`path`, the folder
|
|
703
|
-
// `records.yml` put it in) becomes part of its href — `/blog/field/my-post`.
|
|
704
|
-
if (config.route) {
|
|
705
|
-
const base = config.route.replace(/\/$/, '')
|
|
706
|
-
const template = base.endsWith('/[...path]')
|
|
707
|
-
? `${base.slice(0, -'/[...path]'.length)}/:path*`
|
|
708
|
-
: `${base}/:slug`
|
|
709
|
-
items = items.map((item) => {
|
|
710
|
-
const route = fillRoutePattern(template, item)
|
|
711
|
-
return route === null ? item : { ...item, route }
|
|
712
|
-
})
|
|
713
|
-
}
|
|
714
|
-
|
|
715
688
|
// ⛔ ORDER MATCHES `data-fetcher.js::applyPostProcessing` — where, then sort. Two
|
|
716
689
|
// lanes evaluate the same declaration (this one materializes a query to
|
|
717
690
|
// `/data/<name>.json`; that one runs a page-level `fetch:`), so a difference in
|
|
@@ -742,12 +715,15 @@ async function collectItems(siteDir, config, entitiesDir, basePath, locale = nul
|
|
|
742
715
|
items = applySort(items, config.sort, { locale })
|
|
743
716
|
}
|
|
744
717
|
|
|
745
|
-
// ⛔ `limit` IS NEVER BAKED
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
//
|
|
749
|
-
// `
|
|
750
|
-
//
|
|
718
|
+
// ⛔ `limit` IS NEVER BAKED. A query's `limit` is part of its set (ruled 2026-09-14
|
|
719
|
+
// [Diego]), but the set is made per page: `scope` and the route-bound clauses the
|
|
720
|
+
// runtime applies first differ from page to page, and the `limit` cuts what they
|
|
721
|
+
// leave — so a file cut here would cut before them. Every record the query's fixed
|
|
722
|
+
// `where` selects compiles, in its `sort`; the runtime makes each page's set and then
|
|
723
|
+
// applies the fetch's `narrow` (`@uniweb/core`'s `evaluateQuery`), and a parametric
|
|
724
|
+
// page is expanded over the set (`prerender.js`). ⛔ Until 2026-09-13 this sliced the
|
|
725
|
+
// file — before a page's `scope` or routed clauses, which the runtime has applied
|
|
726
|
+
// since 2026-09-11, could select anything from it.
|
|
751
727
|
|
|
752
728
|
return items
|
|
753
729
|
}
|
package/src/uwx/site.js
CHANGED
|
@@ -803,13 +803,13 @@ const DECL_NOT_ON_WIRE = new Set([
|
|
|
803
803
|
// convention supplied it. Decides hard-error vs soft-skip during sync;
|
|
804
804
|
// `collections-config.js::toConfigQueries` strips it downstream too.
|
|
805
805
|
'schemaExplicit',
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
//
|
|
809
|
-
//
|
|
810
|
-
//
|
|
811
|
-
// passed unknown keys through blindly and would have
|
|
812
|
-
//
|
|
806
|
+
// ⛔ RETIRED 2026-09-14, and kept here so a stale declaration never carries it.
|
|
807
|
+
// `route:` was framework-local — `collectItems` baked each item's link as
|
|
808
|
+
// `<route>/<slug>` — and the backend's Model has no slot for it. It is refused now
|
|
809
|
+
// (`data-fetcher.js::refuseQueryRoute`): a record links to its query's page as
|
|
810
|
+
// `$route`, filled at render time. Measured 2026-08-29: a first version of this list
|
|
811
|
+
// passed unknown keys through blindly and would have sent `route` from every site
|
|
812
|
+
// that declared one.
|
|
813
813
|
'route',
|
|
814
814
|
// Legacy predicate, translated to the canonical `where` upstream. No legacy
|
|
815
815
|
// fields on the wire.
|
package/src/validate-data.js
CHANGED
|
@@ -27,7 +27,7 @@ import { readFile } from 'node:fs/promises'
|
|
|
27
27
|
import { existsSync } from 'node:fs'
|
|
28
28
|
import { join, resolve, basename } from 'node:path'
|
|
29
29
|
import yaml from 'js-yaml'
|
|
30
|
-
import { queryNameFromUrl, siteReaches } from '@uniweb/core'
|
|
30
|
+
import { queryNameFromUrl, siteReaches, declaredKeys, fillDeclaredKeys } from '@uniweb/core'
|
|
31
31
|
|
|
32
32
|
import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
|
|
33
33
|
import { validateAndNormalizeSchema } from './resolve-data-schema.js'
|
|
@@ -103,54 +103,59 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
103
103
|
const work = new Map() // pairKey -> { path, ref, schema, users: [{ route, section, key }] }
|
|
104
104
|
const deferred = []
|
|
105
105
|
|
|
106
|
+
const byRoute = new Map((site.pages || []).map((p) => [p.route, p]))
|
|
106
107
|
for (const page of site.pages || []) {
|
|
107
108
|
walkSections(page.sections || [], (section) => {
|
|
108
109
|
const type = section.type
|
|
109
110
|
if (!type) return
|
|
110
111
|
const bindings = foundation[type]?.data
|
|
111
|
-
//
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
//
|
|
112
|
+
// ⭐ THE RUNTIME'S JOIN (2026-09-14): a section receives the keys its component
|
|
113
|
+
// declares, and which fetch fills each is automatic `as` — `fillDeclaredKeys`, the
|
|
114
|
+
// same function the entity store delivers by — so a `post: '@std/article'` key
|
|
115
|
+
// filled by an `articles` fetch is checked against `@std/article`, and a fetch that
|
|
116
|
+
// fills no declared key is not checked at all, since no component receives it. The
|
|
117
|
+
// levels are the section's own fetch, its page's, its parent page's and — for a
|
|
118
|
+
// top-level page — the site's (`siteReaches`).
|
|
119
|
+
const declared = declaredKeys(bindings)
|
|
120
|
+
const levels = [section.fetch, page.fetch, byRoute.get(page.parent)?.fetch, siteReaches(page.parent) ? config.fetch : null]
|
|
121
|
+
const inputs = collectInputs(levels)
|
|
122
|
+
const held = nodesOfType(section.content, 'dataBlock').map((node) => node.attrs?.tag).filter(Boolean)
|
|
123
|
+
const fills = fillDeclaredKeys(declared, levels, { queries: config.queries, held })
|
|
124
|
+
|
|
125
|
+
// ⭐ **THE JOIN, RUN THE OTHER WAY: data arrived, and no key this section reads is
|
|
126
|
+
// filled by it.** Everything below asks "for each filled key, is its data right?".
|
|
127
|
+
// This asks "was anything this section reads filled?" — and the answer was silence
|
|
128
|
+
// until 2026-09-02.
|
|
118
129
|
//
|
|
119
|
-
// A section reads `content.data.<key>` for the keys its `meta.js` `data:`
|
|
120
|
-
//
|
|
121
|
-
// section renders its heading and nothing else: no error, no warning, HTTP
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
130
|
+
// A section reads `content.data.<key>` for the keys its `meta.js` `data:` declares.
|
|
131
|
+
// When nothing on the page fills them — no fetch under the key, none of its schema —
|
|
132
|
+
// the section renders its heading and nothing else: no error, no warning, HTTP 200,
|
|
133
|
+
// a clean console. Reported by `flows`, measured in a real browser — two
|
|
134
|
+
// records-backed sections carrying 8 and 6 characters of text beside static ones
|
|
135
|
+
// carrying 182/572/289/529/99.
|
|
125
136
|
//
|
|
126
|
-
// ⚖️ **Narrow on purpose: only when SOMETHING was delivered.**
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const declaredKeys = bindings ? Object.keys(bindings) : []
|
|
133
|
-
if (declaredKeys.length > 0 && inputs.length > 0) {
|
|
137
|
+
// ⚖️ **Narrow on purpose: only when SOMETHING was delivered.** A section declaring
|
|
138
|
+
// keys on a page with no data at all is ordinary and silent. What is not ordinary is
|
|
139
|
+
// a page that fetched something and a section on it that receives none of it —
|
|
140
|
+
// there the author demonstrably intended data to arrive and the names did not meet.
|
|
141
|
+
const declaredNames = declared.map(([key]) => key)
|
|
142
|
+
if (declaredNames.length > 0 && inputs.length > 0 && fills.size === 0 && !declaredNames.some((k) => held.includes(k))) {
|
|
134
143
|
const delivered = inputs.map((i) => i.as).filter(Boolean)
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
})
|
|
148
|
-
}
|
|
144
|
+
setupErrors.push({
|
|
145
|
+
file: `${page.route || '/'} · ${type}`,
|
|
146
|
+
message:
|
|
147
|
+
`section reads ${declaredNames.map((k) => `content.data.${k}`).join(' or ')}, ` +
|
|
148
|
+
`but this page delivers ${delivered.map((k) => `\`${k}\``).join(', ')}, and none of it fills them — ` +
|
|
149
|
+
`not by name, and not by schema. The section will render with no data and nothing else will say so. ` +
|
|
150
|
+
`Name the query for the key the section reads, give the fetch that key with \`as:\`, or give the ` +
|
|
151
|
+
`section its own \`query: <name>\`.`,
|
|
152
|
+
// One user per declared key, so `uniweb validate` can print
|
|
153
|
+
// `used by /team › Team › data.team` — the key is the thing to rename.
|
|
154
|
+
users: declaredNames.map((k) => ({ route: page.route, section: type, key: k })),
|
|
155
|
+
})
|
|
149
156
|
}
|
|
150
157
|
|
|
151
|
-
for (const input of
|
|
152
|
-
const key = input.as // the content.data KEY
|
|
153
|
-
|
|
158
|
+
for (const [key, { fetch: input }] of fills) {
|
|
154
159
|
// An external query's records are its address's, fetched where the page renders.
|
|
155
160
|
const external = typeof input.query === 'string' ? config.queries?.[input.query]?.url : undefined
|
|
156
161
|
if (input.url || external) {
|
|
@@ -161,7 +166,7 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
161
166
|
|
|
162
167
|
const binding = bindings?.[key]
|
|
163
168
|
const ref = typeof binding === 'string' ? binding : binding?.schema
|
|
164
|
-
if (!ref) continue // ungoverned
|
|
169
|
+
if (!ref) continue // ungoverned key — no schema declared for it
|
|
165
170
|
|
|
166
171
|
const schema = dataSchemas[ref]
|
|
167
172
|
if (!schema) continue // build guarantees refs resolve; defensive skip
|
|
@@ -475,17 +480,17 @@ async function loadStandardSchemas() {
|
|
|
475
480
|
}
|
|
476
481
|
|
|
477
482
|
/**
|
|
478
|
-
* The data inputs
|
|
479
|
-
*
|
|
480
|
-
*
|
|
481
|
-
*
|
|
483
|
+
* The data inputs reaching a section, deduped by key — the fetches of its levels, most
|
|
484
|
+
* specific first; when two levels share a key, the nearer one wins. What each input fills
|
|
485
|
+
* is `fillDeclaredKeys`'s to say; this lists what arrived, for the message when nothing
|
|
486
|
+
* the section reads is filled.
|
|
482
487
|
*/
|
|
483
|
-
function collectInputs(
|
|
488
|
+
function collectInputs(levels) {
|
|
484
489
|
const byKey = new Map()
|
|
485
490
|
// ⭐ Each level may declare SEVERAL — `query: [team, articles]` — so each is
|
|
486
491
|
// flattened rather than read. Order is least- to most-specific and `set`
|
|
487
492
|
// overwrites, which is what makes a section's declaration win the key.
|
|
488
|
-
for (const source of [
|
|
493
|
+
for (const source of [...levels].reverse()) {
|
|
489
494
|
for (const f of toFetchList(source)) {
|
|
490
495
|
// ⛔ Gate on the BINDING KEY. A gate on the wrong name silently yields
|
|
491
496
|
// NOTHING here — no inputs collected, no violations found, a green run —
|