@uniweb/build 0.34.0 → 0.36.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/prerender.js +29 -21
- package/src/site/content-collector.js +60 -15
- package/src/site/data-fetcher.js +90 -21
- package/src/site/fetch-shapes.js +2 -2
- package/src/site/plugin.js +12 -13
- package/src/uwx/site.js +30 -7
- package/src/validate-data.js +59 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,15 +59,15 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.35.3",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
-
"@uniweb/schemas": "^0.2.13",
|
|
63
|
-
"@uniweb/content-reader": "^1.2.4",
|
|
64
|
-
"@uniweb/theming": "^0.1.15",
|
|
65
62
|
"@uniweb/semantic-parser": "^1.4.0",
|
|
66
|
-
"@uniweb/
|
|
67
|
-
"@uniweb/
|
|
63
|
+
"@uniweb/content-writer": "^0.3.4",
|
|
64
|
+
"@uniweb/theming": "^0.1.15",
|
|
65
|
+
"@uniweb/content-reader": "^1.2.4",
|
|
66
|
+
"@uniweb/schemas": "^0.2.13",
|
|
67
|
+
"@uniweb/projections": "^0.5.7"
|
|
68
68
|
},
|
|
69
69
|
"optionalDependencies": {
|
|
70
|
-
"@uniweb/runtime": "^0.
|
|
70
|
+
"@uniweb/runtime": "^0.14.0"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|
|
73
73
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
"@tailwindcss/vite": "^4.0.0",
|
|
77
77
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
78
78
|
"vite-plugin-svgr": "^4.0.0",
|
|
79
|
-
"@uniweb/core": "^0.
|
|
79
|
+
"@uniweb/core": "^0.19.0"
|
|
80
80
|
},
|
|
81
81
|
"peerDependenciesMeta": {
|
|
82
82
|
"vite": {
|
package/src/prerender.js
CHANGED
|
@@ -12,7 +12,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'
|
|
|
12
12
|
import { join, dirname, resolve } from 'node:path'
|
|
13
13
|
import { pathToFileURL } from 'node:url'
|
|
14
14
|
import { resolveDefaultLocale, isDataUrl } from '@uniweb/core'
|
|
15
|
-
import { executeFetch, mergeDataIntoContent } from './site/data-fetcher.js'
|
|
15
|
+
import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
|
|
16
16
|
import { shouldSplitContent } from './site/split-content.js'
|
|
17
17
|
import { FONT_LINKS_MARKER } from './site/head-markers.js'
|
|
18
18
|
import { getAdapter } from './hosts/index.js'
|
|
@@ -98,11 +98,13 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
|
|
|
98
98
|
? { siteRoot: localeInfo.distDir, publicDir: '.' }
|
|
99
99
|
: fetchOptions
|
|
100
100
|
|
|
101
|
-
// 1. Site-level fetch
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
// 1. Site-level fetch. ⛔ `toFetchList` rather than a property read: a `fetch:`
|
|
102
|
+
// or `data:` LIST parses to an array, and `siteFetch.prerender` on one is
|
|
103
|
+
// `undefined` — which passes the `!== false` test and then fetches nothing.
|
|
104
|
+
for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
|
|
105
|
+
if (oneFetch.prerender === false) continue
|
|
106
|
+
const cfg = localizeFetch(oneFetch)
|
|
107
|
+
const opts = cfg !== oneFetch ? localizedFetchOptions : fetchOptions
|
|
106
108
|
onProgress(` Fetching site data: ${cfg.path || cfg.url}`)
|
|
107
109
|
const result = await executeFetch(cfg, opts)
|
|
108
110
|
if (result.data && !result.error) {
|
|
@@ -114,20 +116,25 @@ async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
|
|
|
114
116
|
const pageFetchedData = new Map()
|
|
115
117
|
|
|
116
118
|
for (const page of siteContent.pages || []) {
|
|
117
|
-
// Page-level fetch
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
const cfg = localizeFetch(
|
|
121
|
-
const opts = cfg !==
|
|
119
|
+
// Page-level fetch — every declaration on the page.
|
|
120
|
+
for (const oneFetch of toFetchList(page.fetch)) {
|
|
121
|
+
if (oneFetch.prerender === false) continue
|
|
122
|
+
const cfg = localizeFetch(oneFetch)
|
|
123
|
+
const opts = cfg !== oneFetch ? localizedFetchOptions : fetchOptions
|
|
122
124
|
onProgress(` Fetching page data for ${page.route}: ${cfg.path || cfg.url}`)
|
|
123
125
|
const result = await executeFetch(cfg, opts)
|
|
124
126
|
if (result.data && !result.error) {
|
|
125
127
|
fetchedData.push({ config: cfg, data: result.data, _scope: page.route })
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
// ⚖️ Dynamic-route expansion consumes ONE query — a `[slug]` template
|
|
129
|
+
// expands over a single record set. With several declared, the first
|
|
130
|
+
// that prerenders is the route query, matching `parentSchema` in the
|
|
131
|
+
// collector; `expandDynamicPages` is what reads this back.
|
|
132
|
+
if (!pageFetchedData.has(page.route)) {
|
|
133
|
+
pageFetchedData.set(page.route, {
|
|
134
|
+
schema: oneFetch.as,
|
|
135
|
+
data: result.data,
|
|
136
|
+
})
|
|
137
|
+
}
|
|
131
138
|
}
|
|
132
139
|
}
|
|
133
140
|
|
|
@@ -295,17 +302,18 @@ async function processSectionFetches(sections, fetchOptions, onProgress) {
|
|
|
295
302
|
if (!sections || !Array.isArray(sections)) return
|
|
296
303
|
|
|
297
304
|
for (const section of sections) {
|
|
298
|
-
// Execute section-level fetch
|
|
299
|
-
|
|
300
|
-
|
|
305
|
+
// Execute every section-level fetch. Each merges under its own key, so
|
|
306
|
+
// several accumulate into one `parsedContent.data` — the same keyed map the
|
|
307
|
+
// runtime's EntityStore builds.
|
|
308
|
+
for (const sectionFetch of toFetchList(section.fetch)) {
|
|
309
|
+
if (sectionFetch.prerender === false) continue
|
|
301
310
|
onProgress(` Fetching section data: ${sectionFetch.path || sectionFetch.url}`)
|
|
302
311
|
const result = await executeFetch(sectionFetch, fetchOptions)
|
|
303
312
|
if (result.data && !result.error) {
|
|
304
|
-
// Merge fetched data into section's parsedContent
|
|
305
313
|
section.parsedContent = mergeDataIntoContent(
|
|
306
314
|
section.parsedContent || {},
|
|
307
315
|
result.data,
|
|
308
|
-
sectionFetch.
|
|
316
|
+
sectionFetch.as,
|
|
309
317
|
sectionFetch.merge
|
|
310
318
|
)
|
|
311
319
|
}
|
|
@@ -32,7 +32,7 @@ import yaml from 'js-yaml'
|
|
|
32
32
|
import { collectSectionAssets, mergeAssetCollections, collectConfigAssets } from './assets.js'
|
|
33
33
|
import { collectSectionIcons, mergeIconCollections, buildIconManifest } from './icons.js'
|
|
34
34
|
import { normalizeHideIn, dropUnpublishedPages } from './nav-visibility.js'
|
|
35
|
-
import { parseFetchConfig } from './data-fetcher.js'
|
|
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
38
|
import { resolveDefaultLocale, resolvePublishableLocales, validateLanguageConfig } from '@uniweb/core'
|
|
@@ -127,6 +127,28 @@ function detectVersions(folderNames) {
|
|
|
127
127
|
return versions
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Desugar a `data:` declaration into a `fetch:` one.
|
|
132
|
+
*
|
|
133
|
+
* `data: team` → `{ query: 'team' }`; `data: [team, articles]` → one config per
|
|
134
|
+
* entry. ⭐ **A list means "fetch each"** — see `parseFetchConfig` for why the
|
|
135
|
+
* declaration is plural by necessity and why that is not a statement about
|
|
136
|
+
* request count.
|
|
137
|
+
*
|
|
138
|
+
* ⛔ Before 2026-09-02 a list kept `[0]` and dropped the rest **silently**: no
|
|
139
|
+
* warning, no error, and the array was not carried forward on the section, so
|
|
140
|
+
* nothing downstream could recover it. An author writing a list got one dataset
|
|
141
|
+
* and a section rendering empty.
|
|
142
|
+
*
|
|
143
|
+
* @param {string|Array<string>|undefined} data
|
|
144
|
+
* @returns {Object|Array<Object>|undefined}
|
|
145
|
+
*/
|
|
146
|
+
function fetchFromDataShorthand(data) {
|
|
147
|
+
if (!data) return undefined
|
|
148
|
+
if (Array.isArray(data)) return data.map((query) => ({ query }))
|
|
149
|
+
return { query: data }
|
|
150
|
+
}
|
|
151
|
+
|
|
130
152
|
/**
|
|
131
153
|
* Build version metadata from detected versions and page.yml config
|
|
132
154
|
* @param {Array<Object>} detectedVersions - Detected version infos
|
|
@@ -860,14 +882,28 @@ async function processMarkdownFile(filePath, id, siteRoot, defaultStableId = nul
|
|
|
860
882
|
// Extract @ component references → insets (mutates doc)
|
|
861
883
|
const insets = extractInsets(proseMirrorContent)
|
|
862
884
|
|
|
863
|
-
//
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
885
|
+
// `data:` shorthand — `data: team` → `fetch: { query: team }`.
|
|
886
|
+
//
|
|
887
|
+
// ⛔ **A LIST KEEPS ONLY `[0]`. THE REST ARE INERT.** This comment used to say
|
|
888
|
+
// *"others via inheritData"*, and that is wrong in a way worth spelling out,
|
|
889
|
+
// because it read as a mechanism and was copied into a second doc as one.
|
|
890
|
+
//
|
|
891
|
+
// `inheritData` is a BOOLEAN OPT-OUT on a section's `meta.js`
|
|
892
|
+
// (`meta.inheritData === false` → deliver nothing). It never consumed list
|
|
893
|
+
// elements. What actually happens is that delivery is default-on and
|
|
894
|
+
// collect-all: `EntityStore._getRequestedSchemas` returns `[]`, so a section
|
|
895
|
+
// receives EVERY fetch config in the section → page → site cascade whether or
|
|
896
|
+
// not it named any of them.
|
|
897
|
+
//
|
|
898
|
+
// ⇒ So `articles` arrives only if something ELSE already declared a fetch for
|
|
899
|
+
// it — in which case this section would have received it anyway. **Naming it
|
|
900
|
+
// here contributes nothing.** Measured 2026-09-02: `data: [team, articles]`
|
|
901
|
+
// with no other declaration yields exactly one config, `team`, and the array
|
|
902
|
+
// is not carried forward on the section, so nothing downstream can recover it.
|
|
903
|
+
//
|
|
904
|
+
// ⚠️ The list form appears in no `docs/` page, so nothing promises it works.
|
|
905
|
+
// Whether it should mean "fetch each" or be refused outright is undecided.
|
|
906
|
+
const resolvedFetch = fetch || fetchFromDataShorthand(data)
|
|
871
907
|
|
|
872
908
|
// Stable ID for scroll targeting: frontmatter id > filename-derived > null
|
|
873
909
|
// This ID is stable across reordering (unlike the positional id)
|
|
@@ -1378,11 +1414,22 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1378
1414
|
// Layout panel visibility (from object form of layout config)
|
|
1379
1415
|
const layoutObj = typeof layoutConfig === 'object' && layoutConfig !== null ? layoutConfig : {}
|
|
1380
1416
|
|
|
1381
|
-
// For dynamic routes, determine the parent's data schema
|
|
1382
|
-
//
|
|
1417
|
+
// For dynamic routes, determine the parent's data schema — this tells
|
|
1418
|
+
// prerender which data array to iterate over.
|
|
1419
|
+
//
|
|
1420
|
+
// ⚖️ **A `[slug]` template expands over exactly ONE record set**, so a plural
|
|
1421
|
+
// parent declaration has to resolve to one query here. The first is taken,
|
|
1422
|
+
// matching what prerender records in `pageFetchedData`; the two must agree or
|
|
1423
|
+
// expansion iterates a set the route was not built from.
|
|
1424
|
+
//
|
|
1425
|
+
// ⛔ This is a genuine cardinality constraint, not a limit worth lifting: a
|
|
1426
|
+
// route pattern names one variable, and "which collection does `:slug` index"
|
|
1427
|
+
// has no second answer. A page that needs another dataset alongside its
|
|
1428
|
+
// dynamic one still declares it — plurality is what makes that sayable.
|
|
1383
1429
|
let parentSchema = null
|
|
1384
1430
|
if (isDynamic && parentFetch) {
|
|
1385
|
-
|
|
1431
|
+
const [first] = toFetchList(parentFetch)
|
|
1432
|
+
parentSchema = first ? first.as : null
|
|
1386
1433
|
}
|
|
1387
1434
|
|
|
1388
1435
|
return {
|
|
@@ -1450,9 +1497,7 @@ async function processPage(pagePath, pageName, siteRoot, { isIndex = false, pare
|
|
|
1450
1497
|
// Support 'data:' shorthand at page level
|
|
1451
1498
|
// data: team → fetch: { query: team }
|
|
1452
1499
|
fetch: parseFetchConfig(
|
|
1453
|
-
pageConfig.fetch || (pageConfig.data
|
|
1454
|
-
? { query: Array.isArray(pageConfig.data) ? pageConfig.data[0] : pageConfig.data }
|
|
1455
|
-
: undefined)
|
|
1500
|
+
pageConfig.fetch || fetchFromDataShorthand(pageConfig.data)
|
|
1456
1501
|
),
|
|
1457
1502
|
|
|
1458
1503
|
hasContent: hierarchicalSections.length > 0,
|
package/src/site/data-fetcher.js
CHANGED
|
@@ -251,13 +251,13 @@ export function applyPostProcessing(data, config) {
|
|
|
251
251
|
// not understand, but we can refuse to pretend it was never there. Reported
|
|
252
252
|
// once per key name per process so a 200-record build does not print 200 lines.
|
|
253
253
|
const RECOGNIZED_FETCH_KEYS = {
|
|
254
|
-
refine: new Set(['refine', '
|
|
254
|
+
refine: new Set(['refine', 'detail', 'limit', 'sort', 'where', 'filter']),
|
|
255
255
|
query: new Set([
|
|
256
|
-
'query', 'schema', 'prerender', 'merge', 'transform',
|
|
256
|
+
'query', 'as', 'schema', 'prerender', 'merge', 'transform',
|
|
257
257
|
'where', 'limit', 'sort', 'detailPage', 'filter',
|
|
258
258
|
]),
|
|
259
259
|
source: new Set([
|
|
260
|
-
'path', 'url', 'schema', 'prerender', 'merge', 'transform', 'detail',
|
|
260
|
+
'path', 'url', 'as', 'schema', 'prerender', 'merge', 'transform', 'detail',
|
|
261
261
|
'detailPage', 'where', 'limit', 'sort', 'filter',
|
|
262
262
|
]),
|
|
263
263
|
}
|
|
@@ -283,16 +283,68 @@ export function _resetUnknownFetchKeyWarnings() {
|
|
|
283
283
|
warnedUnknownFetchKeys.clear()
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* Normalize a parsed `fetch` to a list. **Use this at every consumption point.**
|
|
288
|
+
*
|
|
289
|
+
* `parseFetchConfig` returns an object for one declaration and an array for
|
|
290
|
+
* several, so `cfg.path` on a multi-fetch page reads `undefined` rather than
|
|
291
|
+
* throwing — the silent-empty class. Reaching for this instead of a property is
|
|
292
|
+
* what keeps that from happening.
|
|
293
|
+
*
|
|
294
|
+
* @param {Object|Array|null} fetch - a PARSED fetch (post-`parseFetchConfig`).
|
|
295
|
+
* @returns {Array<Object>} zero, one, or many configs.
|
|
296
|
+
*/
|
|
297
|
+
export function toFetchList(fetch) {
|
|
298
|
+
if (!fetch) return []
|
|
299
|
+
return Array.isArray(fetch) ? fetch : [fetch]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Parse a `fetch:` (or desugared `data:`) declaration.
|
|
304
|
+
*
|
|
305
|
+
* ⭐ **A LIST MEANS "FETCH EACH".** `data: [team, articles]` declares two needs
|
|
306
|
+
* and they land under two keys in `content.data` — a component reads
|
|
307
|
+
* `content.data.team` and `content.data.articles` independently, so the
|
|
308
|
+
* declaration is plural by necessity.
|
|
309
|
+
*
|
|
310
|
+
* ⚖️ **Plural DECLARATIONS are not plural REQUESTS.** How many round trips this
|
|
311
|
+
* becomes belongs to the fetcher: `EntityStore` already assembles every config
|
|
312
|
+
* before dispatching any of them and awaits them together, which is exactly
|
|
313
|
+
* where a batching source would coalesce. Nothing here should encode a
|
|
314
|
+
* transport assumption — the file lane genuinely has two artifacts, and most
|
|
315
|
+
* sources cannot batch at all.
|
|
316
|
+
*
|
|
317
|
+
* ⛔ **A one-entry list collapses to an object, deliberately.** The returned
|
|
318
|
+
* shape reflects the cardinality of the RESULT, not of the input syntax, so
|
|
319
|
+
* every declaration that resolves to a single fetch is byte-identical to what
|
|
320
|
+
* this emitted before — the array shape appears only where content could not
|
|
321
|
+
* previously have worked. (Before 2026-09-02 a list kept `[0]` and discarded the
|
|
322
|
+
* rest silently, so the only content whose shape changes is content that was
|
|
323
|
+
* already broken.)
|
|
324
|
+
*
|
|
325
|
+
* @param {string|Object|Array|null} fetch
|
|
326
|
+
* @returns {Object|Array<Object>|null}
|
|
327
|
+
*/
|
|
286
328
|
export function parseFetchConfig(fetch) {
|
|
287
329
|
if (!fetch) return null
|
|
288
330
|
|
|
331
|
+
if (Array.isArray(fetch)) {
|
|
332
|
+
const parsed = fetch.map((f) => parseFetchConfig(f)).filter(Boolean)
|
|
333
|
+
// Flatten: a nested array is not a meaningful authoring shape, and letting
|
|
334
|
+
// one through would put an array inside an array where every consumer
|
|
335
|
+
// expects configs.
|
|
336
|
+
const flat = parsed.flat()
|
|
337
|
+
if (flat.length === 0) return null
|
|
338
|
+
return flat.length === 1 ? flat[0] : flat
|
|
339
|
+
}
|
|
340
|
+
|
|
289
341
|
// Simple string: "/data/team.json"
|
|
290
342
|
if (typeof fetch === 'string') {
|
|
291
|
-
const
|
|
343
|
+
const inferred = inferSchemaFromPath(fetch)
|
|
292
344
|
return {
|
|
293
345
|
path: fetch,
|
|
294
346
|
url: undefined,
|
|
295
|
-
|
|
347
|
+
as: inferred,
|
|
296
348
|
prerender: true,
|
|
297
349
|
merge: false,
|
|
298
350
|
transform: undefined,
|
|
@@ -302,24 +354,30 @@ export function parseFetchConfig(fetch) {
|
|
|
302
354
|
// Full config object
|
|
303
355
|
if (typeof fetch !== 'object') return null
|
|
304
356
|
|
|
357
|
+
// ⛔ THE RETIRED ALIAS IS AN ERROR, NOT A WARNING — same reasoning as
|
|
358
|
+
// `collection:` below. `inherit: true` was the earlier spelling of
|
|
359
|
+
// `refine: true`, accepted with a warning from April 2026 and removed on
|
|
360
|
+
// 2026-09-02. Ignored, `{ inherit: true, limit: 3 }` would fall through to
|
|
361
|
+
// the source shape, find neither `path` nor `url`, and resolve to null — a
|
|
362
|
+
// silently empty block.
|
|
363
|
+
if (fetch.inherit !== undefined) {
|
|
364
|
+
throw new Error(
|
|
365
|
+
'[uniweb] fetch: `inherit: true` is retired. Write `refine: true` — the same ' +
|
|
366
|
+
'per-instance refinement of the ancestor fetch, under its current name.'
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
|
|
305
370
|
// Refine config: { refine: true, detail: false, limit: 3 }
|
|
306
371
|
// No URL — merges with the parent fetch config at runtime; only carries
|
|
307
|
-
// override props.
|
|
308
|
-
// release with a warning, then removed.
|
|
372
|
+
// override props.
|
|
309
373
|
//
|
|
310
374
|
// Note on build-vs-runtime scope: this parser passes `sort` and `filter`
|
|
311
375
|
// through on refine configs, but the runtime EntityStore only applies
|
|
312
376
|
// `detail`, `limit`, and `order` overrides. `sort` / `filter` on a refine
|
|
313
377
|
// block are currently accepted by the parser but not honored at runtime.
|
|
314
378
|
// Preserved as-is in this rename commit; revisit separately if needed.
|
|
315
|
-
if (fetch.refine === true
|
|
379
|
+
if (fetch.refine === true) {
|
|
316
380
|
warnUnknownFetchKeys(fetch, 'refine')
|
|
317
|
-
if (fetch.inherit === true && fetch.refine !== true) {
|
|
318
|
-
console.warn(
|
|
319
|
-
"[uniweb] 'fetch: { inherit: true }' is deprecated; rename to 'fetch: { refine: true }'. " +
|
|
320
|
-
'Accepted for one release; will be removed in the next minor.'
|
|
321
|
-
)
|
|
322
|
-
}
|
|
323
381
|
if (fetch.filter !== undefined) warnFilterDeprecated()
|
|
324
382
|
return {
|
|
325
383
|
refine: true,
|
|
@@ -368,10 +426,15 @@ export function parseFetchConfig(fetch) {
|
|
|
368
426
|
query: fetch.query,
|
|
369
427
|
path: queryDataUrl(fetch.query),
|
|
370
428
|
url: undefined,
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
429
|
+
// ⭐ **`as` is the BINDING KEY** — the `content.data.<key>` a component
|
|
430
|
+
// reads — defaulting to the query name. It was called `schema` until
|
|
431
|
+
// 2026-09-02, which collided with the MODEL REF of the same name on a
|
|
432
|
+
// `queries` declaration. `fetch.schema` is still accepted as INPUT, so
|
|
433
|
+
// content authored before the rename keeps working; it is never emitted.
|
|
434
|
+
// ⭐ `fetch.schema` here is an AUTHOR's older spelling in a content file —
|
|
435
|
+
// the same boundary normalization as the source-shape branch below, not
|
|
436
|
+
// the internal alias (removed 2026-09-02). One name travels inside.
|
|
437
|
+
as: fetch.as || fetch.schema || fetch.query,
|
|
375
438
|
prerender: fetch.prerender ?? true,
|
|
376
439
|
merge: fetch.merge ?? false,
|
|
377
440
|
transform: fetch.transform,
|
|
@@ -391,6 +454,7 @@ export function parseFetchConfig(fetch) {
|
|
|
391
454
|
const {
|
|
392
455
|
path,
|
|
393
456
|
url,
|
|
457
|
+
as,
|
|
394
458
|
schema,
|
|
395
459
|
prerender = url ? false : true,
|
|
396
460
|
merge = false,
|
|
@@ -413,7 +477,12 @@ export function parseFetchConfig(fetch) {
|
|
|
413
477
|
return {
|
|
414
478
|
path,
|
|
415
479
|
url,
|
|
416
|
-
|
|
480
|
+
// ⭐ **The one place `schema` is still read, and it is a BOUNDARY
|
|
481
|
+
// normalization — not the internal alias.** An author may have written
|
|
482
|
+
// `fetch: { schema: person }` before the 2026-09-02 rename; that is content
|
|
483
|
+
// on someone's disk, not a field our own packages pass to each other.
|
|
484
|
+
// Translating it here means one name — `as` — everywhere inside.
|
|
485
|
+
as: as ?? schema ?? inferSchemaFromPath(path || url),
|
|
417
486
|
prerender,
|
|
418
487
|
merge,
|
|
419
488
|
transform,
|
|
@@ -589,7 +658,7 @@ export function mergeDataIntoContent(content, fetchedData, schema, merge = false
|
|
|
589
658
|
*
|
|
590
659
|
* @param {object[]} configs - Array of normalized fetch configs
|
|
591
660
|
* @param {object} options - Execution options (same as executeFetch)
|
|
592
|
-
* @returns {Promise<Map<string, any>>} Map of
|
|
661
|
+
* @returns {Promise<Map<string, any>>} Map of binding key (`as`) -> data
|
|
593
662
|
*/
|
|
594
663
|
export async function executeMultipleFetches(configs, options = {}) {
|
|
595
664
|
if (!configs || configs.length === 0) {
|
|
@@ -599,7 +668,7 @@ export async function executeMultipleFetches(configs, options = {}) {
|
|
|
599
668
|
const results = await Promise.all(
|
|
600
669
|
configs.map(async (config) => {
|
|
601
670
|
const result = await executeFetch(config, options)
|
|
602
|
-
return { schema: config.
|
|
671
|
+
return { schema: config.as, data: result.data }
|
|
603
672
|
})
|
|
604
673
|
)
|
|
605
674
|
|
package/src/site/fetch-shapes.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// ⛔ WHY THIS EXISTS. A `fetch:` declaration has three shapes, and the keys each one
|
|
4
4
|
// accepts differ (`data-fetcher.js` RECOGNIZED_FETCH_KEYS):
|
|
5
5
|
//
|
|
6
|
-
// refine refine ·
|
|
6
|
+
// refine refine · detail · limit · sort · where · filter
|
|
7
7
|
// query query · schema · … — and NOT `path`/`url`
|
|
8
8
|
// source path · url · schema · …
|
|
9
9
|
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
/** Which of the three shapes a declaration is — the same order `data-fetcher` uses. */
|
|
40
40
|
export function fetchShapeOf(fetch) {
|
|
41
41
|
if (!fetch || typeof fetch !== 'object') return null
|
|
42
|
-
if (fetch.refine === true
|
|
42
|
+
if (fetch.refine === true) return 'refine'
|
|
43
43
|
if (fetch.query) return 'query'
|
|
44
44
|
return 'source'
|
|
45
45
|
}
|
package/src/site/plugin.js
CHANGED
|
@@ -51,7 +51,7 @@ import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
|
51
51
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
52
52
|
import { processQueries, writeQueryFiles } from './query-processor.js'
|
|
53
53
|
import { ENTITIES_DIR } from './entity-pool.js'
|
|
54
|
-
import { executeFetch, mergeDataIntoContent } from './data-fetcher.js'
|
|
54
|
+
import { executeFetch, mergeDataIntoContent, toFetchList } from './data-fetcher.js'
|
|
55
55
|
import { shouldSplitContent } from './split-content.js'
|
|
56
56
|
import { FONT_LINKS_MARKER } from './head-markers.js'
|
|
57
57
|
import { collectExtensionAssets } from './emit-extensions.js'
|
|
@@ -137,9 +137,9 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
137
137
|
const fetchOptions = { siteRoot: siteDir, publicDir: 'public' }
|
|
138
138
|
const fetchedData = []
|
|
139
139
|
|
|
140
|
-
// Site-level fetch
|
|
141
|
-
const siteFetch
|
|
142
|
-
|
|
140
|
+
// Site-level fetch — every declaration.
|
|
141
|
+
for (const siteFetch of toFetchList(siteContent.config?.fetch)) {
|
|
142
|
+
if (!shouldPrefetchInDev(siteFetch)) continue
|
|
143
143
|
const result = await executeFetch(siteFetch, fetchOptions)
|
|
144
144
|
if (result.data && !result.error) {
|
|
145
145
|
fetchedData.push({ config: siteFetch, data: result.data })
|
|
@@ -148,9 +148,9 @@ async function executeDevFetches(siteContent, siteDir) {
|
|
|
148
148
|
|
|
149
149
|
// Process each page
|
|
150
150
|
for (const page of siteContent.pages || []) {
|
|
151
|
-
// Page-level fetch
|
|
152
|
-
const pageFetch
|
|
153
|
-
|
|
151
|
+
// Page-level fetch — every declaration.
|
|
152
|
+
for (const pageFetch of toFetchList(page.fetch)) {
|
|
153
|
+
if (!shouldPrefetchInDev(pageFetch)) continue
|
|
154
154
|
const result = await executeFetch(pageFetch, fetchOptions)
|
|
155
155
|
if (result.data && !result.error) {
|
|
156
156
|
fetchedData.push({ config: pageFetch, data: result.data })
|
|
@@ -176,17 +176,16 @@ async function processDevSectionFetches(sections, fetchOptions) {
|
|
|
176
176
|
if (!sections || !Array.isArray(sections)) return
|
|
177
177
|
|
|
178
178
|
for (const section of sections) {
|
|
179
|
-
// Execute section-level fetch
|
|
180
|
-
const sectionFetch
|
|
181
|
-
|
|
179
|
+
// Execute every section-level fetch, so dev shows what prerender will.
|
|
180
|
+
for (const sectionFetch of toFetchList(section.fetch)) {
|
|
181
|
+
if (!shouldPrefetchInDev(sectionFetch)) continue
|
|
182
182
|
const result = await executeFetch(sectionFetch, fetchOptions)
|
|
183
183
|
if (result.data && !result.error) {
|
|
184
|
-
//
|
|
185
|
-
// This matches prerender behavior - section's own fetch goes to content.data
|
|
184
|
+
// A section's own fetch goes to content.data, matching prerender.
|
|
186
185
|
section.parsedContent = mergeDataIntoContent(
|
|
187
186
|
section.parsedContent || {},
|
|
188
187
|
result.data,
|
|
189
|
-
sectionFetch.
|
|
188
|
+
sectionFetch.as,
|
|
190
189
|
sectionFetch.merge
|
|
191
190
|
)
|
|
192
191
|
}
|
package/src/uwx/site.js
CHANGED
|
@@ -220,10 +220,13 @@ function buildPageData(config, ctx) {
|
|
|
220
220
|
setIf(data, 'rewrite', config.rewrite)
|
|
221
221
|
setIf(data, 'layout', config.layout)
|
|
222
222
|
setIf(data, 'seo', config.seo)
|
|
223
|
+
// ⭐ A `data:` LIST means "fetch each" — one declaration per entry. Before
|
|
224
|
+
// 2026-09-02 this kept `[0]` and dropped the rest silently, so the wire
|
|
225
|
+
// carried one dataset for a page that asked for several.
|
|
223
226
|
let fetch =
|
|
224
227
|
config.fetch ??
|
|
225
228
|
(config.data
|
|
226
|
-
?
|
|
229
|
+
? (Array.isArray(config.data) ? config.data.map((query) => ({ query })) : { query: config.data })
|
|
227
230
|
: undefined)
|
|
228
231
|
// Resolve the authored `query:` shorthand to the runtime-fetchable
|
|
229
232
|
// `path: /data/<name>.json` (the static convention the default-fetcher uses).
|
|
@@ -232,8 +235,14 @@ function buildPageData(config, ctx) {
|
|
|
232
235
|
// it would never resolve at render (the static build resolves it the same way
|
|
233
236
|
// in site/data-fetcher.js parseFetchConfig). The gateway serves the collection
|
|
234
237
|
// at `<base>/data/<name>.json`.
|
|
235
|
-
|
|
236
|
-
|
|
238
|
+
// ⛔ **Mapped, not read.** A `data:`/`fetch:` LIST reaches here as an array, and
|
|
239
|
+
// `fetch.query` on one is `undefined` — so a property test would skip the
|
|
240
|
+
// resolution below and put bare `{ query }` entries on the wire with no
|
|
241
|
+
// `path`, no `as` and no `schema`. That is the silent-empty class: a payload
|
|
242
|
+
// that arrives, parses, and resolves to nothing.
|
|
243
|
+
const resolveWireFetch = (one) => {
|
|
244
|
+
if (!one || typeof one.query !== 'string') return one
|
|
245
|
+
const { query, ...rest } = one
|
|
237
246
|
// ⭐ BOTH, deliberately, and they are not redundant.
|
|
238
247
|
//
|
|
239
248
|
// `query` — the author's named query, unresolved. A consumer that can ask
|
|
@@ -249,9 +258,9 @@ function buildPageData(config, ctx) {
|
|
|
249
258
|
// `path` once it has resolved an address — matching parseFetchConfig, which
|
|
250
259
|
// has always returned early on the shorthand.
|
|
251
260
|
//
|
|
252
|
-
// `
|
|
253
|
-
// dataStore cache key (deriveCacheKey hashes {path,url,endpoint,
|
|
254
|
-
//
|
|
261
|
+
// `as` (the query name) is BOTH the content.data key and part of the
|
|
262
|
+
// dataStore cache key (deriveCacheKey hashes {path,url,endpoint,as,…}; the
|
|
263
|
+
// shorthand is ignored). Mirrors the static build's parseFetchConfig —
|
|
255
264
|
// ⚠️ which it did NOT until 2026-09-02. This line emitted `query` and that
|
|
256
265
|
// one did not, for the same declaration, so `resolveQuerySource` fired on a
|
|
257
266
|
// published site and never on a `--link`-deployed one: same site, two verbs,
|
|
@@ -263,8 +272,22 @@ function buildPageData(config, ctx) {
|
|
|
263
272
|
// validating — so `fetch` is a blob they carry and framework owns its
|
|
264
273
|
// vocabulary. ⇒ There was nothing to coordinate, and inventing a coordination
|
|
265
274
|
// is how a name stays wrong.
|
|
266
|
-
|
|
275
|
+
// ⚠️ Owning the vocabulary is not the same as nobody reading it. A backend
|
|
276
|
+
// that re-implements the render payload for a lane with no build step reads
|
|
277
|
+
// ONE key out of this blob — `as`, to derive which record set a dynamic route
|
|
278
|
+
// iterates — and that read went silently empty when the key moved on
|
|
279
|
+
// 2026-09-02. So `fetch.as` is on the coupled surface even though `fetch`
|
|
280
|
+
// is carried: a consumer that interprets a name is a reader of it, however
|
|
281
|
+
// little of the object it looks at.
|
|
282
|
+
// ⛔ `as`, the BINDING KEY — not to be confused with `schema` at
|
|
283
|
+
// DECL_EMITTED_ABOVE / `setIf(data,'schema',d.schema)` below, which is a
|
|
284
|
+
// queries decl's MODEL REF and keeps its name. One word meant both until
|
|
285
|
+
// 2026-09-02; this is the half that moved, and the compatibility duplicate
|
|
286
|
+
// that briefly rode alongside it was dropped 2026-09-02 once frontend and
|
|
287
|
+
// hosting had dropped theirs. ⇒ A site synced before that must be re-pushed.
|
|
288
|
+
return { query, path: queryDataUrl(query), as: query, ...rest }
|
|
267
289
|
}
|
|
290
|
+
fetch = Array.isArray(fetch) ? fetch.map(resolveWireFetch) : resolveWireFetch(fetch)
|
|
268
291
|
setIf(data, 'fetch', fetch)
|
|
269
292
|
if (isDynamic) {
|
|
270
293
|
data.is_dynamic = true
|
package/src/validate-data.js
CHANGED
|
@@ -37,6 +37,7 @@ import { validateAndNormalizeSchema } from './resolve-data-schema.js'
|
|
|
37
37
|
// reach it here) even though the implementation moved next to the vocabulary.
|
|
38
38
|
export { validateItem, isStaticallyCheckable } from '@uniweb/schemas/conform'
|
|
39
39
|
import { buildSchema } from './schema.js'
|
|
40
|
+
import { toFetchList } from './site/data-fetcher.js'
|
|
40
41
|
import { resolveFoundationSrcPath } from './utils/foundation-source-root.js'
|
|
41
42
|
import { collectSiteContent } from './site/content-collector.js'
|
|
42
43
|
import { processQueries } from './site/query-processor.js'
|
|
@@ -94,6 +95,10 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
94
95
|
byQuery = await processQueries(siteRoot, config.queries, config.paths?.entities, basePath)
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// Declared here rather than beside pass 2's other accumulators because pass 1
|
|
99
|
+
// now writes to it as well — see THE JOIN, RUN THE OTHER WAY below.
|
|
100
|
+
const setupErrors = []
|
|
101
|
+
|
|
97
102
|
// Pass 1 — discover unique (file, schema-ref) pairs and who uses each.
|
|
98
103
|
const work = new Map() // pairKey -> { path, ref, schema, users: [{ route, section, key }] }
|
|
99
104
|
const deferred = []
|
|
@@ -103,8 +108,47 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
103
108
|
const type = section.type
|
|
104
109
|
if (!type) return
|
|
105
110
|
const bindings = foundation[type]?.data
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
const inputs = collectInputs(section, page.fetch, config.fetch)
|
|
112
|
+
|
|
113
|
+
// ⭐ **THE JOIN, RUN THE OTHER WAY: data arrived, but under no name this
|
|
114
|
+
// section reads.** Everything below asks "for each input, is there a
|
|
115
|
+
// binding?". This asks "for each binding, was anything delivered?" — and
|
|
116
|
+
// the answer was silence until 2026-09-02.
|
|
117
|
+
//
|
|
118
|
+
// A section reads `content.data.<key>` for the keys its `meta.js` `data:`
|
|
119
|
+
// declares. When the page delivers a query under a DIFFERENT name, the
|
|
120
|
+
// section renders its heading and nothing else: no error, no warning, HTTP
|
|
121
|
+
// 200, a clean console. Reported by `flows`, measured in a real browser —
|
|
122
|
+
// two records-backed sections carrying 8 and 6 characters of text beside
|
|
123
|
+
// static ones carrying 182/572/289/529/99.
|
|
124
|
+
//
|
|
125
|
+
// ⚖️ **Narrow on purpose: only when SOMETHING was delivered.** `data:` in
|
|
126
|
+
// `meta.js` is a hint rather than a delivery gate (`docs/reference/
|
|
127
|
+
// data-fetching.md`), so a section declaring keys on a page with no data at
|
|
128
|
+
// all is ordinary and silent. What is not ordinary is a page that fetched
|
|
129
|
+
// something and a section on it that reads none of it — there the author
|
|
130
|
+
// demonstrably intended data to arrive and the names did not meet.
|
|
131
|
+
const declaredKeys = bindings ? Object.keys(bindings) : []
|
|
132
|
+
if (declaredKeys.length > 0 && inputs.length > 0) {
|
|
133
|
+
const delivered = inputs.map((i) => i.as).filter(Boolean)
|
|
134
|
+
if (delivered.length > 0 && !declaredKeys.some((k) => delivered.includes(k))) {
|
|
135
|
+
setupErrors.push({
|
|
136
|
+
file: `${page.route || '/'} · ${type}`,
|
|
137
|
+
message:
|
|
138
|
+
`section reads ${declaredKeys.map((k) => `content.data.${k}`).join(' or ')}, ` +
|
|
139
|
+
`but this page delivers ${delivered.map((k) => `\`${k}\``).join(', ')}. ` +
|
|
140
|
+
`The section will render with no data and nothing else will say so. ` +
|
|
141
|
+
`Name the query for the key the section reads, or give the section its own ` +
|
|
142
|
+
`\`fetch: { query: <name> }\`.`,
|
|
143
|
+
// One user per declared key, so `uniweb validate` can print
|
|
144
|
+
// `used by /team › Team › data.team` — the key is the thing to rename.
|
|
145
|
+
users: declaredKeys.map((k) => ({ route: page.route, section: type, key: k })),
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const input of inputs) {
|
|
151
|
+
const key = input.as // the content.data KEY
|
|
108
152
|
|
|
109
153
|
if (input.url) {
|
|
110
154
|
deferred.push({ route: page.route, section: type, key, reason: 'remote url: source', url: input.url })
|
|
@@ -137,7 +181,6 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
|
|
|
137
181
|
|
|
138
182
|
// Pass 2 — validate each unique pair ONCE, attribute findings to its users.
|
|
139
183
|
const violations = []
|
|
140
|
-
const setupErrors = []
|
|
141
184
|
const schemasSeen = new Set()
|
|
142
185
|
let recordCount = 0
|
|
143
186
|
|
|
@@ -436,9 +479,19 @@ async function loadStandardSchemas() {
|
|
|
436
479
|
*/
|
|
437
480
|
function collectInputs(section, pageFetch, siteFetch) {
|
|
438
481
|
const byKey = new Map()
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
482
|
+
// ⭐ Each level may declare SEVERAL — `data: [team, articles]` — so each is
|
|
483
|
+
// flattened rather than read. Order is least- to most-specific and `set`
|
|
484
|
+
// overwrites, which is what makes a section's declaration win the key.
|
|
485
|
+
for (const source of [siteFetch, pageFetch, section.fetch]) {
|
|
486
|
+
for (const f of toFetchList(source)) {
|
|
487
|
+
// ⛔ Gate on the BINDING KEY. A gate on the wrong name silently yields
|
|
488
|
+
// NOTHING here — no inputs collected, no violations found, a green run —
|
|
489
|
+
// which is exactly how the rename was caught: the integration test went
|
|
490
|
+
// from flagging the seeded violations to flagging none.
|
|
491
|
+
const key = f?.as
|
|
492
|
+
if (f && (f.path || f.url) && typeof key === 'string') {
|
|
493
|
+
byKey.set(key, f)
|
|
494
|
+
}
|
|
442
495
|
}
|
|
443
496
|
}
|
|
444
497
|
return [...byKey.values()]
|