@uniweb/build 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.54.0",
3
+ "version": "0.55.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.5",
61
- "@uniweb/semantic-parser": "^1.4.1",
62
- "@uniweb/schemas": "^0.3.3",
63
61
  "@uniweb/content-writer": "^0.3.4",
62
+ "@uniweb/projections": "^0.7.3",
64
63
  "@uniweb/theming": "^0.1.15",
65
- "@uniweb/projections": "^0.7.3"
64
+ "@uniweb/semantic-parser": "^1.4.1",
65
+ "@uniweb/schemas": "^0.3.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "@uniweb/runtime": "^0.26.5"
68
+ "@uniweb/runtime": "^0.27.0"
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/runtime": "^0.26.5",
78
- "@uniweb/core": "^0.29.4"
77
+ "@uniweb/core": "^0.29.6",
78
+ "@uniweb/runtime": "^0.27.0"
79
79
  },
80
80
  "peerDependenciesMeta": {
81
81
  "vite": {
package/src/prerender.js CHANGED
@@ -25,7 +25,9 @@ import {
25
25
  evaluateQuery,
26
26
  } from '@uniweb/core'
27
27
  import { recordTitle, routePatternToRegex } from '@uniweb/core/route-match'
28
+ import DataStore from '@uniweb/core/datastore'
28
29
  import { executeFetch, mergeDataIntoContent, toFetchList } from './site/data-fetcher.js'
30
+ import { createFileTransport } from './site/file-transport.js'
29
31
  import { shouldSplitContent } from './site/split-content.js'
30
32
  import { FONT_LINKS_MARKER } from './site/head-markers.js'
31
33
  import { getAdapter } from './hosts/index.js'
@@ -89,9 +91,10 @@ export function resolveExtensionPath(url, distDir, projectRoot, base) {
89
91
  * @param {string} [localeInfo.locale] - Active locale code
90
92
  * @param {string} [localeInfo.defaultLocale] - Default locale code
91
93
  * @param {string} [localeInfo.distDir] - Path to dist directory (where locale-specific data lives)
92
- * @returns {Object} { fetched, fetchedData, bake } - what each level fetched, by binding key
93
- * (for parametric-page expansion); the entries for DataStore pre-population; and a
94
- * baker for the views a concrete parametric page binds to its route
94
+ * @returns {Object} { fetched, fetchedData } - what each level fetched, by binding key (for
95
+ * parametric-page expansion), and the entries for DataStore pre-population. ⛔ It no longer
96
+ * bakes the views an expanded page binds to its route: the render loop asks for those by the
97
+ * rule the render uses, per page (`loadPageData`, 2026-09-20).
95
98
  */
96
99
  export async function executeAllFetches(siteContent, siteDir, onProgress, localeInfo) {
97
100
  // The locale this pass renders, which a `sort` collates texts in.
@@ -171,7 +174,15 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
171
174
  // `undefined` — which passes the `!== false` test and then fetches nothing.
172
175
  const firstSite = firstOfKey()
173
176
  for (const oneFetch of toFetchList(siteContent.config?.fetch)) {
174
- if (!firstSite(oneFetch.as)) continue
177
+ // ⭐ EVERY binding is FETCHED; only the first per key is KEPT as the key's set.
178
+ // Ruled 2026-09-20 [Diego]: where several fetches of one schema meet several declared
179
+ // keys of it and no `as` pairs them, they pair POSITIONALLY — so a second binding under
180
+ // one key fills another key and its data is needed. ⛔ Skipping it baked nothing for that
181
+ // key and the section rendered empty. What stays first-per-key is the SET a parametric
182
+ // page expands over, which is the route query's — and it is counted before
183
+ // `prerender: false` is honoured, so a deferred first binding is not replaced by a baked
184
+ // second.
185
+ const isFirst = firstSite(oneFetch.as)
175
186
  // ⭐ `prerender` is read off the RESOLVED config: an external query's binding is the
176
187
  // browser's unless it says otherwise, which only the resolver knows.
177
188
  const cfg = resolveForBuild(oneFetch)
@@ -180,7 +191,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
180
191
  const got = await read(cfg, oneFetch)
181
192
  if (got) {
182
193
  fetchedData.push(entry(cfg, got.view, '__site__'))
183
- fetched.site.set(oneFetch.as, got.selection)
194
+ if (isFirst) fetched.site.set(oneFetch.as, got.selection)
184
195
  }
185
196
  }
186
197
 
@@ -201,14 +212,15 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
201
212
  // Page-level fetch — every declaration on the page.
202
213
  const firstPage = firstOfKey()
203
214
  for (const oneFetch of toFetchList(page.fetch)) {
204
- if (!firstPage(oneFetch.as)) continue
215
+ // Every binding fetched, the first per key kept as the key's set — see the site level.
216
+ const isFirst = firstPage(oneFetch.as)
205
217
  const cfg = resolveForBuild(oneFetch)
206
218
  if (cfg.prerender === false) continue
207
219
  onProgress(` Fetching page data for ${page.route}: ${cfg.path || cfg.url}`)
208
220
  const got = await read(cfg, oneFetch)
209
221
  if (got) {
210
222
  fetchedData.push(entry(cfg, got.view, page.route))
211
- keep(fetched.pages, page.route, oneFetch.as, got.selection)
223
+ if (isFirst) keep(fetched.pages, page.route, oneFetch.as, got.selection)
212
224
  }
213
225
  }
214
226
 
@@ -218,7 +230,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
218
230
  // key, a view its route binds under any other. A list baked into the template
219
231
  // was cloned into every expanded page and outranked both (measured 2026-09-13:
220
232
  // `/blog/b` rendered all three records). Its views are read per expanded page
221
- // instead (`readRouteBoundViews`).
233
+ // instead — by the render loop's data step, per page (`loadPageData`).
222
234
  await processSectionFetches(page.sections, {
223
235
  resolve: resolveForBuild,
224
236
  read,
@@ -228,21 +240,7 @@ export async function executeAllFetches(siteContent, siteDir, onProgress, locale
228
240
  })
229
241
  }
230
242
 
231
- /**
232
- * Read one config the runtime resolved for an expanded parametric page — a view
233
- * its route binds — into a plain entry; `readRouteBoundViews` files it under
234
- * each page that asks for it. Local files only: a remote `url:` is the
235
- * browser's, as it is by default. Null when there is nothing to embed.
236
- */
237
- const bake = async (cfg) => {
238
- if (cfg.prerender === false || (typeof cfg.path !== 'string' && typeof cfg.url !== 'string')) return null
239
- const options = isNonDefaultLocale && typeof cfg.path === 'string' && cfg.path.startsWith(`/${localeInfo.locale}/`) ? localizedFetchOptions : fetchOptions
240
- const result = await executeFetch(cfg, options)
241
- if (!result.data || result.error) return null
242
- return { config: cfg, data: result.data, meta: { whole: cfg.whole } }
243
- }
244
-
245
- return { fetched, fetchedData, bake }
243
+ return { fetched, fetchedData }
246
244
  }
247
245
 
248
246
  /**
@@ -622,7 +620,7 @@ function stripFetchScope(entry) {
622
620
  */
623
621
  export function scopeFetchedData(fetchedData, scopeRoutes, currentRoute = null) {
624
622
  if (!Array.isArray(fetchedData)) return fetchedData
625
- // A route-bound entry (`readRouteBoundViews`) belongs to one expanded page, in
623
+ // A route-bound entry — what a page's own data step asked for — belongs to that page, in
626
624
  // either mode: carried everywhere, a site of N such pages would embed N views —
627
625
  // or N whole records — in every page.
628
626
  const own = (e) => !e._routeBound || e._scope === currentRoute
@@ -663,54 +661,6 @@ function dedupeByAddress(entries) {
663
661
  return out
664
662
  }
665
663
 
666
- /**
667
- * The views an expanded parametric page binds to its route — `scope: :dir` bound to
668
- * its branch, a `deferred:` query's per-record file — that no list page asked for.
669
- * Each is resolved by the RUNTIME's own rule for the page (`resolvePageFetchConfigs`,
670
- * the one a host's prefetch calls, matched against the parametric page it came
671
- * from) and read by `read`, so the page renders complete and the SPA hydrates the
672
- * very keys it asks for.
673
- *
674
- * ⭐ ONE ENTRY PER PAGE, ONE READ PER VIEW. Each entry is tagged `_routeBound` with
675
- * its page's route: `scopeFetchedData` embeds it in that page's HTML and nowhere
676
- * else, and the split-mode manifest leaves it out — so N expanded pages do not
677
- * carry N views (or N whole records) each. Pages that bind the same view (two
678
- * entries in one branch) each get an entry, over a single read. ⛔ Filed under
679
- * the first page that asked for it, a shared view reached no other page's HTML
680
- * (measured: the second entry in a branch shipped without its branch's view).
681
- *
682
- * A key already `present` is not read: every page carries those already — the
683
- * site's, and, in split mode, its parent's and its template's (the render loop's
684
- * `scopeRoutes`).
685
- *
686
- * @param {Object} options
687
- * @param {Object} options.templates - the content as it was before expansion (the parametric pages)
688
- * @param {Array<Object>} options.pages - the pages after expansion
689
- * @param {Array<Object>} options.present - the entries already baked
690
- * @param {Function} options.resolvePageFetchConfigs - `@uniweb/runtime/ssr`'s
691
- * @param {(cfg: Object) => Promise<{config: Object, data: any, meta?: Object}|null>} options.read - reads one config
692
- * @param {string|null} [options.locale]
693
- * @returns {Promise<Array<Object>>} the new entries, each for its own page
694
- */
695
- export async function readRouteBoundViews({ templates, pages, present, resolvePageFetchConfigs, read, locale = null }) {
696
- const carried = new Set((present || []).map((e) => deriveCacheKey(e.config)))
697
- const reads = new Map()
698
- const out = []
699
- for (const page of pages || []) {
700
- if (!page?.dynamicContext) continue
701
- const filed = new Set()
702
- for (const cfg of resolvePageFetchConfigs(templates, page.route, { locale })) {
703
- const key = deriveCacheKey(cfg)
704
- if (carried.has(key) || filed.has(key)) continue
705
- filed.add(key)
706
- if (!reads.has(key)) reads.set(key, read(cfg))
707
- const view = await reads.get(key)
708
- if (view) out.push({ ...view, _scope: page.route, _routeBound: true })
709
- }
710
- }
711
- return out
712
- }
713
-
714
664
  /**
715
665
  * Inject build-specific data into HTML (theme CSS, __SITE_CONTENT__, icon cache).
716
666
  * Called after the shared injectPageContent for build-specific additions.
@@ -862,7 +812,7 @@ export async function prerenderSite(siteDir, options = {}) {
862
812
  prefetchIcons,
863
813
  createPageRenderer,
864
814
  generate404Html,
865
- resolvePageFetchConfigs,
815
+ loadPageData,
866
816
  } = await import('@uniweb/runtime/ssr')
867
817
 
868
818
  // Load default site content
@@ -941,30 +891,15 @@ export async function prerenderSite(siteDir, options = {}) {
941
891
  // Expand dynamic pages (e.g., /blog/:slug → /blog/post-1, /blog/post-2)
942
892
  if (siteContent.pages?.some(p => p.isDynamic)) {
943
893
  onProgress('Expanding dynamic routes...')
944
- const templates = { ...siteContent, pages: siteContent.pages }
945
894
  siteContent.pages = expandDynamicPages(siteContent.pages, fetched, onProgress, undefined, {
946
895
  siteFetch: siteContent.config?.fetch ?? null,
947
896
  })
948
897
 
949
- // ⭐ AN EXPANDED PAGE ASKS FOR WHAT ITS ROUTE BINDS. Its sections read views
950
- // no list page asked for — `scope: :dir` bound to its branch, a `deferred:`
951
- // query's per-record file — and those are resolved here by the RUNTIME's own
952
- // rule for a page (`resolvePageFetchConfigs`, the one a host's prefetch
953
- // calls, matched against the parametric page it came from), then read by
954
- // this build's executor. So the page renders complete, and the SPA hydrates
955
- // the very keys it asks for.
956
- const baked = await readRouteBoundViews({
957
- templates,
958
- pages: siteContent.pages,
959
- present: siteContent.fetchedData,
960
- resolvePageFetchConfigs,
961
- read: bake,
962
- locale,
963
- })
964
- if (baked.length > 0) {
965
- siteContent.fetchedData = [...siteContent.fetchedData, ...baked]
966
- onProgress(` Read ${baked.length} route-bound view(s) for expanded pages`)
967
- }
898
+ // ⭐ AN EXPANDED PAGE ASKS FOR WHAT ITS ROUTE BINDS — its branch's view, a `deferred:`
899
+ // query's per-record file — and that is now asked for in the render loop below, by the
900
+ // rule the render uses, per page (`loadPageData`). ⛔ It was worked out here from the
901
+ // payload alone until 2026-09-20, which cannot build a record's own address from the
902
+ // record: an `[id]` route over a `deferred:` query asked for `{slug}` unfilled.
968
903
  }
969
904
 
970
905
  // Determine whether to split content (after dynamic expansion, after data fetches)
@@ -1022,6 +957,16 @@ export async function prerenderSite(siteDir, options = {}) {
1022
957
  // fixed, so it is created once and asked per page.
1023
958
  const renderer = createPageRenderer({ website: uniweb.activeWebsite, shell: htmlShell })
1024
959
 
960
+ // ⭐ The data step's two halves for this lane: a transport over the files this build has
961
+ // already produced, and one cache for the whole run so an address every page asks for is read
962
+ // once rather than once per page.
963
+ const fileTransport = createFileTransport({
964
+ siteRoot: siteDir,
965
+ distDir,
966
+ base: siteContent.config?.base || '',
967
+ })
968
+ const buildDataCache = new DataStore()
969
+
1025
970
  // Build-specific: pre-populate DataStore so EntityStore can resolve data during prerender.
1026
971
  // hydrateDataStore handles cache-key derivation + value-shape wrapping
1027
972
  // — same helper used by the browser SPA boot and by the Cloudflare
@@ -1090,14 +1035,43 @@ export async function prerenderSite(siteDir, options = {}) {
1090
1035
  }
1091
1036
  }
1092
1037
 
1038
+ // ⭐ WHAT THIS PAGE'S RENDER WILL READ — asked by the rule the render itself uses
1039
+ // (`loadPageData`, `@uniweb/runtime/ssr`) and answered from this build's own files. It walks
1040
+ // the page's blocks: its body, the layout areas it draws, child blocks and insets.
1041
+ //
1042
+ // ⛔ Until 2026-09-20 this lane worked the answer out its own way, and got two things wrong
1043
+ // that nothing else could see: a LAYOUT section's data was never fetched at all — the
1044
+ // executor walks the site, each page and each page's sections, and layout areas sit beside
1045
+ // the pages — and an expanded page's views came from the payload walk, which cannot build a
1046
+ // record's own address from the record. Both render empty in the HTML and fill in once the
1047
+ // SPA boots, so only a crawler ever saw them.
1048
+ //
1049
+ // One cache for the whole run, so an address every page asks for is read once; the author's
1050
+ // `prerender: false` is honoured, which is what `'author'` means and what a baking lane owes.
1051
+ const asked = await loadPageData({
1052
+ website,
1053
+ route: page,
1054
+ fetch: fileTransport,
1055
+ prerender: 'author',
1056
+ cache: buildDataCache,
1057
+ })
1058
+ hydrateDataStore(website, asked)
1059
+ // Filed under this page, the way a route-bound view always has been: `scopeFetchedData`
1060
+ // embeds it in this page's HTML and nowhere else, and one address embedded twice collapses.
1061
+ for (const entry of asked) {
1062
+ if (entry.outcome !== 'fetched') continue
1063
+ siteContent.fetchedData.push({ ...entry, _scope: page.route, _routeBound: true })
1064
+ }
1065
+
1093
1066
  onProgress(`Rendering ${outputRoute}...`)
1094
1067
 
1095
1068
  // ⭐ `renderer.render` IS the sequence this loop used to spell out —
1096
1069
  // renderPage → classify → injectPageContent. It was moved into
1097
1070
  // `@uniweb/runtime/ssr` because an SSR isolate assembles the same three steps
1098
- // per request, and two hand-written copies of one sequence is how the two
1099
- // lanes drift. This lane passes the Page it already holds; the isolate passes
1100
- // a route. See `runtime/src/page-renderer.js` for what it leaves to the host.
1071
+ // for the page a visit starts on, and two hand-written copies of one sequence
1072
+ // is how the two lanes drift. This lane passes the Page it already holds; the
1073
+ // isolate passes a route. See `runtime/src/page-renderer.js` for what it leaves
1074
+ // to the host.
1101
1075
  const result = renderer.render(page)
1102
1076
 
1103
1077
  if (result.outcome === 'failed') {
@@ -412,10 +412,19 @@ function refuseSourceKeys(fetch, context) {
412
412
 
413
413
  /**
414
414
  * ⚠️ TWO BINDINGS UNDER ONE KEY AT ONE LEVEL — ruled 2026-09-13 [Diego]: *"Duplicates
415
- * should not exist."* The first is used on every lane (`resolveFetchConfigs` keeps
416
- * the first per key, and so does the build's prerender) and the rest are ignored;
417
- * this says so once per key per file. Warned, not refused: the first binding still
418
- * delivers what it says.
415
+ * should not exist."* Warned, not refused, once per key per file.
416
+ *
417
+ * ⛔ **What happens to the second one depends on something this parser cannot see** — whether
418
+ * a component declares that key — so the message says both, and this comment no longer claims
419
+ * one. Ruled 2026-09-20 [Diego]: *"if there are x fetches of schema Y, and x+ keys for schema
420
+ * Y, and there is no `as` key that tells us how to pair, then we can auto-pair positionally."*
421
+ * - **a component declares the key** → the first fills it and the rest fill nothing;
422
+ * - **none declares it** → they pair POSITIONALLY with that schema's still-empty declared
423
+ * keys, in order, so the second is used (`fillDeclaredKeys`, measured 2026-09-20).
424
+ *
425
+ * ⚠️ This comment and the message said *"the rest are ignored"* until 2026-09-20, which is
426
+ * false in the second case — and the build's own executor believed it, so a second binding's
427
+ * data was never fetched and the key it fills rendered empty.
419
428
  *
420
429
  * @param {Array<Object>} list - one level's bindings, parsed or as authored
421
430
  * @param {string} context - where the declaration sits, for the message
@@ -435,8 +444,10 @@ export function warnDuplicateBindings(list, context) {
435
444
  if (warnedDuplicateBindings.has(memo)) continue
436
445
  warnedDuplicateBindings.add(memo)
437
446
  console.warn(
438
- `[uniweb] ${context}: more than one binding delivers content.data.${key} — the first is used ` +
439
- `and the rest are ignored. Give each binding its own \`as:\`.`
447
+ `[uniweb] ${context}: more than one binding is named ${key}. A component that declares ` +
448
+ `content.data.${key} receives the first, and the rest fill nothing; if none declares it, ` +
449
+ `they pair in order with the declared keys of their schema. Give each binding its own ` +
450
+ `\`as:\` to say which is which.`
440
451
  )
441
452
  }
442
453
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * A `fetch`-shaped transport over the files this build has already produced.
3
+ *
4
+ * ⭐ **Why a transport rather than a reader.** The render's data step
5
+ * (`@uniweb/runtime/ssr::loadPageData`) asks for what a page's render will read, by the rule the
6
+ * render itself uses, and dispatches every request through one function the caller supplies. A host
7
+ * gives it the network; this lane gives it `dist/` and `public/`. So the build asks the same
8
+ * question as every other lane and only the answering differs — which is the whole point of
9
+ * `kb/framework/plans/what-a-page-needs.md`.
10
+ *
11
+ * ⛔ **A remote `url:` is still the browser's lane by default.** Nothing here decides that: the
12
+ * author's `prerender:` does, and the step is run with `prerender: 'author'` so a deferred request
13
+ * is never dispatched. One that IS asked for at build time is fetched the way a browser would.
14
+ *
15
+ * ⚖️ **`dist/` first, then `public/`** — a non-default locale's data is written under
16
+ * `dist/{locale}/data/…` and the resolver localizes the path, so the localized address is found
17
+ * where it was written; everything else sits in `public/`, which is where the query processor
18
+ * compiles a query's file.
19
+ *
20
+ * @module
21
+ */
22
+ import { existsSync } from 'node:fs'
23
+ import { readFile } from 'node:fs/promises'
24
+ import { join } from 'node:path'
25
+
26
+ /** The site-relative path an address names, with the site's base removed. */
27
+ function pathOf(target, base) {
28
+ const clean = String(target).split('#')[0]
29
+ if (!base || base === '/') return clean
30
+ const prefix = base.endsWith('/') ? base.slice(0, -1) : base
31
+ return clean.startsWith(prefix) ? clean.slice(prefix.length) || '/' : clean
32
+ }
33
+
34
+ const response = (body, contentType) => ({
35
+ ok: true,
36
+ status: 200,
37
+ statusText: 'OK',
38
+ headers: { get: (name) => (String(name).toLowerCase() === 'content-type' ? contentType : null) },
39
+ json: async () => JSON.parse(body),
40
+ text: async () => body,
41
+ })
42
+
43
+ const missing = (path) => ({
44
+ ok: false,
45
+ status: 404,
46
+ statusText: `no file for ${path}`,
47
+ headers: { get: () => 'text/plain' },
48
+ json: async () => null,
49
+ text: async () => '',
50
+ })
51
+
52
+ /**
53
+ * @param {Object} where
54
+ * @param {string} where.siteRoot - the site directory
55
+ * @param {string|null} [where.distDir] - its build output, searched first
56
+ * @param {string} [where.publicDir] - the static directory under `siteRoot`
57
+ * @param {string} [where.base] - the site's base path, which addresses carry and files do not
58
+ * @returns {(input: *, init?: Object) => Promise<Object>} a `fetch`
59
+ */
60
+ export function createFileTransport({ siteRoot, distDir = null, publicDir = 'public', base = '' }) {
61
+ const roots = [distDir, join(siteRoot, publicDir)].filter(Boolean)
62
+
63
+ return async function fileTransport(input, init) {
64
+ const target = String(input?.url ?? input)
65
+ // Absolute: a remote address, fetched as the browser would.
66
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return globalThis.fetch(target, init)
67
+
68
+ const path = pathOf(target, base)
69
+ for (const root of roots) {
70
+ const file = join(root, path)
71
+ if (!existsSync(file)) continue
72
+ const body = await readFile(file, 'utf8')
73
+ return response(body, path.endsWith('.json') ? 'application/json' : 'text/plain')
74
+ }
75
+ // ⚖️ Not an error here. A page can ask for a per-record file a `deferred:` query never wrote,
76
+ // and the step reports the outcome so the caller can see it rather than crash a build.
77
+ return missing(path)
78
+ }
79
+ }
@@ -28,7 +28,8 @@ import { existsSync } from 'node:fs'
28
28
  import { join, resolve, basename } from 'node:path'
29
29
  import yaml from 'js-yaml'
30
30
  import { YAML_OPTIONS } from './utils/yaml-schema.js'
31
- import { queryNameFromUrl, siteReaches, declaredKeys, fillDeclaredKeys } from '@uniweb/core'
31
+ import { queryNameFromUrl, declaredKeys, fillDeclaredKeys, fetchLevels, pageRouteQuery } from '@uniweb/core'
32
+ import { parentRouteOf } from '@uniweb/core/route-match'
32
33
 
33
34
  import { validateItem, isStaticallyCheckable, validateBound } from '@uniweb/schemas/conform'
34
35
  import { validateAndNormalizeSchema } from './resolve-data-schema.js'
@@ -105,7 +106,26 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
105
106
  const deferred = []
106
107
 
107
108
  const byRoute = new Map((site.pages || []).map((p) => [p.route, p]))
109
+ // ⭐ The parent by the ONE parent rule, not the declared field alone: a payload may omit
110
+ // `parent`, and reading it raw gives a page no parent here while every other lane infers one —
111
+ // so the site's fetch reached pages it does not reach, and a parent's reached none.
112
+ const parentOf = (page) => {
113
+ if (!page?.route) return null
114
+ const route = parentRouteOf(page.route, { declared: page.parent ?? null, has: (r) => byRoute.has(r) })
115
+ return route ? byRoute.get(route) ?? null : null
116
+ }
117
+ const access = {
118
+ routeOf: (p) => p?.route,
119
+ parentOf,
120
+ fetchOf: (p) => p?.fetch ?? null,
121
+ sectionsOf: (p) => p?.sections,
122
+ site: config.fetch,
123
+ }
108
124
  for (const page of site.pages || []) {
125
+ const parent = parentOf(page)
126
+ // Its route query, when it is on a parametric route — a page nested inside one included,
127
+ // which is a level this check did not have.
128
+ const route = pageRouteQuery(page, access)
109
129
  walkSections(page.sections || [], (section) => {
110
130
  const type = section.type
111
131
  if (!type) return
@@ -115,10 +135,11 @@ export async function validateDataInputs({ siteRoot, foundationPath }) {
115
135
  // same function the entity store delivers by — so a `post: '@std/article'` key
116
136
  // filled by an `articles` fetch is checked against `@std/article`, and a fetch that
117
137
  // fills no declared key is not checked at all, since no component receives it. The
118
- // levels are the section's own fetch, its page's, its parent page's and — for a
119
- // top-level page — the site's (`siteReaches`).
138
+ // levels are the rule's, so this check sees what the render sees (`fetchLevels`,
139
+ // `@uniweb/core/page-data`) — ⛔ it wrote its own list until 2026-09-19, which read only
140
+ // the declared parent and had no route binding for a nested page.
120
141
  const declared = declaredKeys(bindings)
121
- const levels = [section.fetch, page.fetch, byRoute.get(page.parent)?.fetch, siteReaches(page.parent) ? config.fetch : null]
142
+ const levels = fetchLevels({ own: section.fetch, page: page.fetch, parent, route, site: config.fetch })
122
143
  const inputs = collectInputs(levels)
123
144
  const held = nodesOfType(section.content, 'dataBlock').map((node) => node.attrs?.tag).filter(Boolean)
124
145
  const fills = fillDeclaredKeys(declared, levels, { queries: config.queries, held })