@uniweb/runtime 0.14.1 → 0.15.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/dist/ssr.js +206 -356
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/components/BlockRenderer.jsx +7 -0
- package/src/default-fetcher.js +234 -378
- package/src/isolate-api.js +86 -0
- package/src/page-renderer.js +158 -0
- package/src/prefetch.js +71 -17
- package/src/prepare-props.js +1 -1
- package/src/setup.js +7 -5
- package/src/ssr.js +6 -0
- package/src/wire-foundation.js +3 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolate API — what `@uniweb/runtime/ssr` promises a host that renders in an
|
|
3
|
+
* isolate, and the runtime version below which that promise does not hold.
|
|
4
|
+
*
|
|
5
|
+
* ⭐ THE ONE PLACE THE NUMBER IS STATED. A host loads the SITE'S pinned runtime as a
|
|
6
|
+
* dynamically-loaded artifact — never an import — so it cannot link-check what it
|
|
7
|
+
* calls; it feature-detects, and an export that is missing looks exactly like an old
|
|
8
|
+
* runtime. The backend evaluates a site's runtime at publish and holds this floor as
|
|
9
|
+
* a constant of its own [Diego, 2026-09-04: "We will set a runtime version floor that
|
|
10
|
+
* guarantees they are there"], composing it as `max(absoluteFloor, foundationFloors)`.
|
|
11
|
+
* That constant is copied from here, and `tests/isolate-api.test.js` is what keeps
|
|
12
|
+
* this file honest: every export of `src/ssr.js` must appear below with the version
|
|
13
|
+
* it first shipped in, and every name below must still be exported — by the source
|
|
14
|
+
* and by the built `dist/ssr.js` when it is present. Forgetting to stamp a new export
|
|
15
|
+
* fails HERE, in the repo where the change happens; a rename fails here too.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ A floor is a promise about a VERSION, not a rename guard. A site on a newer
|
|
18
|
+
* runtime with a renamed export is still a missing symbol; the test above is what
|
|
19
|
+
* makes that fail before it ships, and the announcement to the consumer is still
|
|
20
|
+
* ours to send (`framework/CLAUDE.md` § Decoupling is the architecture).
|
|
21
|
+
*
|
|
22
|
+
* ⛔ Not `runtime-pin.json`. That file is emitted per FOUNDATION build and records an
|
|
23
|
+
* observed fact ("built against"), never a guarantee; an isolate-API floor is a
|
|
24
|
+
* guarantee and is not a property of any foundation. Different kind of claim,
|
|
25
|
+
* different home.
|
|
26
|
+
*
|
|
27
|
+
* "since" is the first PUBLISHED version (git tag) whose `@uniweb/runtime/ssr`
|
|
28
|
+
* exported the name — measured with `git log --reverse -S<name> -- src/ssr.js` and
|
|
29
|
+
* `git tag --contains`, 2026-09-04.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Every export of `@uniweb/runtime/ssr`, with the version it first shipped in. */
|
|
33
|
+
export const ISOLATE_API = Object.freeze({
|
|
34
|
+
// props preparation
|
|
35
|
+
prepareProps: '0.2.15',
|
|
36
|
+
applySchemas: '0.2.15',
|
|
37
|
+
applyDefaults: '0.2.15',
|
|
38
|
+
guaranteeContentStructure: '0.2.15',
|
|
39
|
+
getComponentMeta: '0.2.15',
|
|
40
|
+
getComponentDefaults: '0.2.15',
|
|
41
|
+
// rendering
|
|
42
|
+
getWrapperProps: '0.6.14',
|
|
43
|
+
renderBackground: '0.6.14',
|
|
44
|
+
renderBlock: '0.6.14',
|
|
45
|
+
renderBlocks: '0.6.14',
|
|
46
|
+
renderLayout: '0.6.14',
|
|
47
|
+
renderPage: '0.2.15',
|
|
48
|
+
classifyRenderError: '0.6.14',
|
|
49
|
+
injectPageContent: '0.6.14',
|
|
50
|
+
escapeHtml: '0.6.14',
|
|
51
|
+
generate404Html: '0.6.16',
|
|
52
|
+
// initialization
|
|
53
|
+
initPrerender: '0.6.14',
|
|
54
|
+
initPrerenderForLocale: '0.8.9',
|
|
55
|
+
sliceContentForLocale: '0.8.9',
|
|
56
|
+
hydrateDataStore: '0.8.9',
|
|
57
|
+
prefetchIcons: '0.6.14',
|
|
58
|
+
renderAppearanceBootScript: '0.8.30',
|
|
59
|
+
// page resolution
|
|
60
|
+
resolvePage: '0.9.5',
|
|
61
|
+
// server-side prefetch — the runtime executing a page's fetches for a host
|
|
62
|
+
findPageForRoute: '0.14.1',
|
|
63
|
+
resolvePageFetchConfigs: '0.14.1',
|
|
64
|
+
executeFetchConfigs: '0.14.1',
|
|
65
|
+
prefetchPageData: '0.14.1',
|
|
66
|
+
// the composed render entry
|
|
67
|
+
createPageRenderer: '0.14.2',
|
|
68
|
+
prefetchAndHydrate: '0.14.2',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The runtime version at or above which EVERY name in `ISOLATE_API` is exported —
|
|
73
|
+
* the absolute floor a host may rely on with no feature detection.
|
|
74
|
+
*/
|
|
75
|
+
export const ISOLATE_API_FLOOR = Object.values(ISOLATE_API).reduce((max, v) => (compareVersions(v, max) > 0 ? v : max), '0.0.0')
|
|
76
|
+
|
|
77
|
+
/** Compare two `x.y.z` versions numerically. Returns <0, 0 or >0. */
|
|
78
|
+
export function compareVersions(a, b) {
|
|
79
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0)
|
|
80
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0)
|
|
81
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
|
|
82
|
+
const d = (pa[i] || 0) - (pb[i] || 0)
|
|
83
|
+
if (d !== 0) return d
|
|
84
|
+
}
|
|
85
|
+
return 0
|
|
86
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composed render entry — resolve a route, render it, inject it into a shell.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ **This exists because framework had two callers of one unshared sequence, not
|
|
5
|
+
* because a consumer asked.** `@uniweb/runtime/ssr` exported every step of the
|
|
6
|
+
* per-page render and never the sequence, so each host assembled it:
|
|
7
|
+
*
|
|
8
|
+
* - `@uniweb/build`'s `prerender.js` — `renderPage` → classify → `injectPageContent`,
|
|
9
|
+
* once per page in its loop.
|
|
10
|
+
* - an SSR isolate rendering per request — `resolvePage` → `renderPage` →
|
|
11
|
+
* `injectPageContent`, and it wrote the route lookup three times in three files
|
|
12
|
+
* before `resolvePage` was exported at all (see that function's header).
|
|
13
|
+
*
|
|
14
|
+
* Two unlike callers is what makes the interface honest: a build bakes files and an
|
|
15
|
+
* isolate answers a request, so anything only one of them needs stayed out.
|
|
16
|
+
*
|
|
17
|
+
* ⛔ WHAT IS DELIBERATELY NOT IN HERE, and the boundary is the point:
|
|
18
|
+
*
|
|
19
|
+
* - **Shell assembly.** The shell arrives built. The import map, the CDN base and
|
|
20
|
+
* cache headers are host layout, and a runtime that assembled them would be
|
|
21
|
+
* modelling a deployment it cannot see (hosting drew this line themselves,
|
|
22
|
+
* 2026-09-03; `framework/CLAUDE.md` § *Serve locations are read, never constructed*).
|
|
23
|
+
* - **Init and hydration.** The two lanes differ REALLY here, not incidentally: a
|
|
24
|
+
* build initializes once and hydrates every collection up front, an isolate
|
|
25
|
+
* initializes per locale and prefetches per route. Folding either in would fit
|
|
26
|
+
* one caller and lie to the other. `initPrerender` / `initPrerenderForLocale` /
|
|
27
|
+
* `prefetchPageData` / `hydrateDataStore` stay separate exports, and
|
|
28
|
+
* `prefetchAndHydrate` below is the isolate's two-step, not a general one.
|
|
29
|
+
* - **Anything build-only.** `injectBuildData` stays in the build lane; it is the
|
|
30
|
+
* other half of the head seam and has its own parity guard.
|
|
31
|
+
*/
|
|
32
|
+
import { resolvePage, renderPage, classifyRenderError, injectPageContent } from './ssr-renderer.js'
|
|
33
|
+
import { hydrateDataStore } from './wire-foundation.js'
|
|
34
|
+
import { prefetchPageData } from './prefetch.js'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A renderer bound to one initialized Website and one shell.
|
|
38
|
+
*
|
|
39
|
+
* Create it once per locale (the Website is already locale-sliced by
|
|
40
|
+
* `initPrerenderForLocale`) and call `render` per route or per page.
|
|
41
|
+
*
|
|
42
|
+
* @param {Object} opts
|
|
43
|
+
* @param {Object} opts.website - `uniweb.activeWebsite`, already initialized
|
|
44
|
+
* @param {string} opts.shell - the HTML shell to inject into, taken as given
|
|
45
|
+
* @returns {{ website: Object, render: Function }}
|
|
46
|
+
*/
|
|
47
|
+
export function createPageRenderer({ website, shell }) {
|
|
48
|
+
if (!website) throw new Error('createPageRenderer: `website` is required')
|
|
49
|
+
if (typeof shell !== 'string') throw new Error('createPageRenderer: `shell` must be an HTML string')
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Render one page.
|
|
53
|
+
*
|
|
54
|
+
* ⭐ Returns an OUTCOME rather than throwing or returning a bare string, because
|
|
55
|
+
* the two callers branch differently on the same three cases and neither wants an
|
|
56
|
+
* exception: a build logs and keeps going so one broken section cannot fail a whole
|
|
57
|
+
* site, an isolate decides a status code and a cache policy. Same reasoning as
|
|
58
|
+
* `prefetchPageData`'s per-entry outcome.
|
|
59
|
+
*
|
|
60
|
+
* @param {string|Object} target - a route (`/blog/1`, resolved through the same
|
|
61
|
+
* matcher the browser uses, so a dynamic route works) or an already-resolved
|
|
62
|
+
* Page, which the build lane already holds from its own loop.
|
|
63
|
+
* @param {Object} [options]
|
|
64
|
+
* @param {Object} [options.inject] - extra options forwarded to `injectPageContent`
|
|
65
|
+
* @returns {{ outcome: 'rendered'|'notFound'|'failed', html: string|null,
|
|
66
|
+
* page: Object|null, error: {type: string, message: string}|null }}
|
|
67
|
+
*/
|
|
68
|
+
function render(target, { inject = {} } = {}) {
|
|
69
|
+
const page = typeof target === 'string' ? resolvePage(website, target) : target
|
|
70
|
+
|
|
71
|
+
// ⛔ Not an error: nothing matched, which is a genuine 404 and the caller's to
|
|
72
|
+
// turn into one — a build skips it, an isolate serves its 404 page with a 404
|
|
73
|
+
// status. Returning `failed` here would make those indistinguishable.
|
|
74
|
+
if (!page) return { outcome: 'notFound', html: null, page: null, error: null }
|
|
75
|
+
|
|
76
|
+
let result
|
|
77
|
+
try {
|
|
78
|
+
result = renderPage(page, website)
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// `renderPage` handles its own errors, but a foundation can throw from
|
|
81
|
+
// module scope in ways it does not catch. Classify rather than propagate,
|
|
82
|
+
// so one page cannot take down a build loop or an isolate's request.
|
|
83
|
+
return { outcome: 'failed', html: null, page, error: classifyRenderError(err) }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (result.error) return { outcome: 'failed', html: null, page, error: result.error }
|
|
87
|
+
|
|
88
|
+
// ⛔ `sectionOverrideCSS` LAST, so a caller's `inject` cannot displace it. It is
|
|
89
|
+
// computed by `renderPage` for this page — theme pinning and component vars —
|
|
90
|
+
// and a caller passing a same-named key would silently drop it, rendering a page
|
|
91
|
+
// that looks fine and is unstyled in exactly the places the author pinned. That
|
|
92
|
+
// is the empty-success shape this module keeps refusing elsewhere; the spread was
|
|
93
|
+
// the other way round for one commit.
|
|
94
|
+
const html = injectPageContent(shell, result.renderedContent, page, {
|
|
95
|
+
...inject,
|
|
96
|
+
sectionOverrideCSS: result.sectionOverrideCSS,
|
|
97
|
+
})
|
|
98
|
+
return { outcome: 'rendered', html, page, error: null }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { website, render }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Prefetch a route's data and hydrate it onto the graph — the isolate's two-step.
|
|
106
|
+
*
|
|
107
|
+
* ⭐ Its whole purpose is that a host stops assembling our structure by hand. It
|
|
108
|
+
* returns the prefetch outcomes rather than swallowing them, because a host reads
|
|
109
|
+
* them to tell "nothing was tried" from "everything tried failed", which is a
|
|
110
|
+
* different cache decision.
|
|
111
|
+
*
|
|
112
|
+
* ⛔ The build lane does NOT call this: it hydrates every collection once, before
|
|
113
|
+
* its page loop, from its own executor that honours the author's `prerender:` flag.
|
|
114
|
+
* That difference is why this is a named isolate helper and not a step inside
|
|
115
|
+
* `render`.
|
|
116
|
+
*
|
|
117
|
+
* @returns {Promise<Array<{config: Object, outcome: string, data: any, error?: string}>>}
|
|
118
|
+
*/
|
|
119
|
+
export async function prefetchAndHydrate({ website, content, route, locale = null, fetch = null, dev = false, prerender = 'always' }) {
|
|
120
|
+
// ⛔ Guarded for the same reason `createPageRenderer` is, and it was not for one
|
|
121
|
+
// commit. `hydrateDataStore` no-ops on a graph with no `dataStore`, so a caller
|
|
122
|
+
// passing the wrong object gets a successful-looking prefetch, an unhydrated graph
|
|
123
|
+
// and a page that renders empty — no error anywhere. Fail where the mistake is.
|
|
124
|
+
if (!website?.dataStore) {
|
|
125
|
+
throw new Error('prefetchAndHydrate: `website` must be an initialized Website with a dataStore')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ⛔ THE TRANSPORT IS REQUIRED HERE, unlike on `prefetchPageData`, and this is the
|
|
129
|
+
// one place the difference matters.
|
|
130
|
+
//
|
|
131
|
+
// A function does NOT survive every isolate boundary. Measured by hosting under
|
|
132
|
+
// `wrangler dev` against a real Worker Loader, 2026-09-03: passed through an
|
|
133
|
+
// entrypoint's `fetch(Request)` with a JSON body the transport arrives
|
|
134
|
+
// **`undefined`**; passed as an argument to an RPC method it arrives as a callable
|
|
135
|
+
// function and the isolate invokes it. Only the RPC shape carries it.
|
|
136
|
+
//
|
|
137
|
+
// ⚠️ And `undefined` is not where it stops, which is the part their measurement
|
|
138
|
+
// could not see from outside our code. `createDefaultFetcher` resolves the
|
|
139
|
+
// transport as `fetchImpl || globalThis.fetch`, so a transport that failed to cross
|
|
140
|
+
// silently becomes THE ISOLATE'S OWN NETWORK — outside the host's timeout, byte
|
|
141
|
+
// budget and site-relative address resolution. With an absolute address it does not
|
|
142
|
+
// even fail: the request goes out from the wrong place and comes back
|
|
143
|
+
// `outcome: 'fetched'`. A wiring mistake wearing a success.
|
|
144
|
+
//
|
|
145
|
+
// ⇒ So the entry whose only caller crosses that boundary demands a real function
|
|
146
|
+
// rather than defaulting. A Node or browser caller that genuinely wants the global
|
|
147
|
+
// passes `fetch: globalThis.fetch` — one word, and it says so.
|
|
148
|
+
if (typeof fetch !== 'function') {
|
|
149
|
+
throw new Error(
|
|
150
|
+
'prefetchAndHydrate: `fetch` must be a function. A transport does not survive a ' +
|
|
151
|
+
'JSON-serialized isolate boundary — pass it as an RPC method argument. ' +
|
|
152
|
+
'To use the ambient fetch deliberately, pass `fetch: globalThis.fetch`.'
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
const fetched = await prefetchPageData({ content, route, locale, fetch, dev, prerender })
|
|
156
|
+
hydrateDataStore(website, fetched)
|
|
157
|
+
return fetched
|
|
158
|
+
}
|
package/src/prefetch.js
CHANGED
|
@@ -15,11 +15,19 @@
|
|
|
15
15
|
*
|
|
16
16
|
* ⛔ Contract with the host, deliberately small:
|
|
17
17
|
* - `content` the render payload (`site-content.json` / `__DATA__`), config included —
|
|
18
|
-
* `config.records
|
|
18
|
+
* `config.records` and `config.base` are read from it.
|
|
19
19
|
* - `route` the page to prefetch for; a `[slug]` template resolves through the same
|
|
20
20
|
* matcher the SPA uses, so `/blog/post-1` finds `/blog/:slug`.
|
|
21
21
|
* - `fetch` how to dispatch a request. The runtime composes the address; the host
|
|
22
22
|
* decides how a site-relative one is reached (its origin, a binding).
|
|
23
|
+
* ⛔ **Crossing an isolate boundary, this survives only as an RPC method
|
|
24
|
+
* argument.** Through an entrypoint's `fetch(Request)` with a serialized
|
|
25
|
+
* body it arrives `undefined` (hosting, measured under `wrangler dev`
|
|
26
|
+
* against a real Worker Loader, 2026-09-03) — and the fetcher then falls
|
|
27
|
+
* back to `globalThis.fetch`, so the request leaves from the isolate,
|
|
28
|
+
* outside whatever budget the host wrapped around it. `prefetchAndHydrate`
|
|
29
|
+
* refuses a non-function for exactly this reason; this entry keeps the
|
|
30
|
+
* permissive default because the build and browser lanes call it in-process.
|
|
23
31
|
* - `prerender` whether a fetch is tried — `'always'` (default) tries every config; `'author'`
|
|
24
32
|
* honours the author's `prerender: false`. ⛔ The default is `'always'` because this
|
|
25
33
|
* entry has exactly one kind of caller: an isolate rendering per request, where the
|
|
@@ -29,7 +37,7 @@
|
|
|
29
37
|
* (`build/src/prerender.js`) and never calls this. `'author'` is the explicit opt-in
|
|
30
38
|
* for a caller that bakes; omitting the option must not silently reproduce the
|
|
31
39
|
* 2026-07-28 outcome — prefetch a no-op on a live-data template, page still 200.
|
|
32
|
-
* - returns one entry per DECLARED config, `{ config, outcome, data, error? }`, keyed
|
|
40
|
+
* - returns one entry per DECLARED config, `{ config, outcome, data, meta?, error? }`, keyed
|
|
33
41
|
* downstream by `deriveCacheKey(config)`. `outcome` is `fetched`, `failed`
|
|
34
42
|
* (transport or HTTP error, `error` says which) or `skipped` (the author
|
|
35
43
|
* deferred it to the browser with `prerender: false`). `hydrateDataStore`
|
|
@@ -42,13 +50,18 @@
|
|
|
42
50
|
*/
|
|
43
51
|
import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
|
|
44
52
|
import { deriveCacheKey } from '@uniweb/core/datastore'
|
|
45
|
-
import { routePatternToRegex } from '@uniweb/core/route-match'
|
|
53
|
+
import { routePatternToRegex, decodeRouteValue, splitPathCapture } from '@uniweb/core/route-match'
|
|
54
|
+
import { buildDetailConfig } from '@uniweb/core/detail-url'
|
|
46
55
|
import { resolveDefaultLocale } from '@uniweb/core/locale-config'
|
|
47
56
|
import { createDefaultFetcher } from './default-fetcher.js'
|
|
48
57
|
|
|
49
58
|
const isRefinement = (f) => f && typeof f === 'object' && f.refine === true
|
|
50
59
|
|
|
51
|
-
/**
|
|
60
|
+
/**
|
|
61
|
+
* The page a route names — exact first, then the `[slug]` / `[...path]` templates, like
|
|
62
|
+
* the SPA. Captured params are decoded the way `matchDynamicRoute` decodes them (a
|
|
63
|
+
* catch-all per segment), so the values are what the site's query is bound against.
|
|
64
|
+
*/
|
|
52
65
|
export function findPageForRoute(content, route) {
|
|
53
66
|
const pages = content?.pages || []
|
|
54
67
|
const exact = pages.find((p) => p.route === route)
|
|
@@ -57,11 +70,35 @@ export function findPageForRoute(content, route) {
|
|
|
57
70
|
if (!page.isDynamic || !page.route) continue
|
|
58
71
|
const compiled = routePatternToRegex(page.route)
|
|
59
72
|
const m = compiled?.regex ? compiled.regex.exec(route) : null
|
|
60
|
-
if (m)
|
|
73
|
+
if (m) {
|
|
74
|
+
const params = {}
|
|
75
|
+
;(compiled.paramNames || []).forEach((n, i) => {
|
|
76
|
+
const raw = m[i + 1]
|
|
77
|
+
params[n] = n === compiled.catchAll ? raw.split('/').map(decodeRouteValue).join('/') : decodeRouteValue(raw)
|
|
78
|
+
})
|
|
79
|
+
return { page, params }
|
|
80
|
+
}
|
|
61
81
|
}
|
|
62
82
|
return { page: null, params: {} }
|
|
63
83
|
}
|
|
64
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The route's variables and the delivery param for a matched template page — the same
|
|
87
|
+
* binding the SPA makes in `Website._createDynamicPage`: `[slug]` binds the one capture
|
|
88
|
+
* under the folder's own name; `[...path]` splits its capture into `path` / `dir` /
|
|
89
|
+
* `slug` and delivers by `slug`, the record's handle.
|
|
90
|
+
*/
|
|
91
|
+
function routeBinding(page, params) {
|
|
92
|
+
const { catchAll } = routePatternToRegex(page.route)
|
|
93
|
+
if (catchAll && params[catchAll] !== undefined) {
|
|
94
|
+
const parts = splitPathCapture(params[catchAll])
|
|
95
|
+
const paramName = page.paramName || 'slug'
|
|
96
|
+
return { paramName, paramValue: parts.slug, variables: { ...params, ...parts } }
|
|
97
|
+
}
|
|
98
|
+
const paramName = page.paramName || Object.keys(params)[0]
|
|
99
|
+
return { paramName, paramValue: params[paramName], variables: { ...params } }
|
|
100
|
+
}
|
|
101
|
+
|
|
65
102
|
/**
|
|
66
103
|
* Every fetch config a page will need at render time, resolved once and de-duplicated by
|
|
67
104
|
* cache key: the site-level fetch, the page's, its parent's, and each section's own
|
|
@@ -71,15 +108,17 @@ export function findPageForRoute(content, route) {
|
|
|
71
108
|
* @returns {Object[]} resolved fetch configs
|
|
72
109
|
*/
|
|
73
110
|
export function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
|
|
74
|
-
const { page } = findPageForRoute(content, route)
|
|
111
|
+
const { page, params } = findPageForRoute(content, route)
|
|
75
112
|
if (!page) return []
|
|
76
113
|
const pages = content?.pages || []
|
|
77
114
|
const parent = page.parent ? pages.find((p) => p.route === page.parent) : null
|
|
115
|
+
const binding = page.isDynamic && Object.keys(params).length ? routeBinding(page, params) : null
|
|
78
116
|
const options = {
|
|
79
117
|
locale,
|
|
80
118
|
defaultLocale: resolveDefaultLocale(content?.config) ?? null,
|
|
81
119
|
queries: content?.config?.queries ?? null,
|
|
82
120
|
records: content?.config?.records ?? null,
|
|
121
|
+
variables: binding?.variables ?? null,
|
|
83
122
|
}
|
|
84
123
|
const out = new Map()
|
|
85
124
|
const add = (sources) => {
|
|
@@ -97,6 +136,23 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
97
136
|
}
|
|
98
137
|
}
|
|
99
138
|
walk(page.sections)
|
|
139
|
+
|
|
140
|
+
// ⭐ A template page is ABOUT one record, and the record is a fetch of its own.
|
|
141
|
+
// The list the page inherits is what the entity store matches the route param
|
|
142
|
+
// against; when that query has a per-record source (a live lane's record address,
|
|
143
|
+
// a `deferred:` query's per-record file), the record itself comes from a second
|
|
144
|
+
// request — which this helper never built, so a host prerendering a template page
|
|
145
|
+
// got the BRIEF and the body arrived after hydration as a client fetch. The
|
|
146
|
+
// detail config is built by the one rule the
|
|
147
|
+
// entity store uses (`buildDetailConfig`), keyed by the route's param.
|
|
148
|
+
if (binding && binding.paramValue !== undefined && page.parentSchema) {
|
|
149
|
+
const listCfg = [...out.values()].find((cfg) => cfg.as === page.parentSchema && cfg.detail)
|
|
150
|
+
const detailCfg = listCfg ? buildDetailConfig(listCfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) }) : null
|
|
151
|
+
if (detailCfg) {
|
|
152
|
+
const key = deriveCacheKey(detailCfg)
|
|
153
|
+
if (!out.has(key)) out.set(key, detailCfg)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
100
156
|
return [...out.values()]
|
|
101
157
|
}
|
|
102
158
|
|
|
@@ -106,7 +162,7 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
|
|
|
106
162
|
* @param {Object[]} configs resolved configs (from `resolvePageFetchConfigs` or the host's own
|
|
107
163
|
* call to `resolveFetchConfigs`)
|
|
108
164
|
* @param {Object} opts
|
|
109
|
-
* @param {Object} opts.content the payload — `config.base`, `config.
|
|
165
|
+
* @param {Object} opts.content the payload — `config.base`, `config.records`
|
|
110
166
|
* @param {Function} [opts.fetch] the transport; defaults to the global `fetch`
|
|
111
167
|
* @param {boolean} [opts.dev]
|
|
112
168
|
* @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
|
|
@@ -117,26 +173,24 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
|
|
|
117
173
|
}
|
|
118
174
|
const fetcher = createDefaultFetcher({
|
|
119
175
|
basePath: content?.config?.base || '',
|
|
120
|
-
config: content?.config?.fetcher ?? {},
|
|
121
176
|
records: content?.config?.records ?? null,
|
|
122
177
|
dev,
|
|
123
178
|
fetch,
|
|
124
179
|
})
|
|
125
180
|
const ctx = { website: null }
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
181
|
+
// Dispatched together, not one after another: a question door batches the
|
|
182
|
+
// requests issued in one tick into one POST, and a page's configs are
|
|
183
|
+
// independent of each other. Order is preserved in the result.
|
|
184
|
+
return Promise.all((configs || []).filter(Boolean).map(async (config) => {
|
|
129
185
|
if (prerender === 'author' && config.prerender === false) {
|
|
130
186
|
// The author deferred this one to the browser and the caller honours that. Present, so a
|
|
131
187
|
// host can count what was declared against what was tried; not hydrated.
|
|
132
|
-
|
|
133
|
-
continue
|
|
188
|
+
return { config, outcome: 'skipped', data: null }
|
|
134
189
|
}
|
|
135
190
|
const result = await fetcher.resolve(config, ctx)
|
|
136
|
-
if (result?.error)
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
return out
|
|
191
|
+
if (result?.error) return { config, outcome: 'failed', data: null, error: result.error }
|
|
192
|
+
return { config, outcome: 'fetched', data: result?.data ?? null, ...(result?.meta ? { meta: result.meta } : {}) }
|
|
193
|
+
}))
|
|
140
194
|
}
|
|
141
195
|
|
|
142
196
|
/** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
|
package/src/prepare-props.js
CHANGED
|
@@ -266,7 +266,7 @@ function applyRichSchemaToValue(value, schema) {
|
|
|
266
266
|
* form-definition schema — which cannot name the author's fields — can never
|
|
267
267
|
* reach into them. It fills the envelope defaults its own author declared.
|
|
268
268
|
*
|
|
269
|
-
* (Established with the editor team, 2026-07-31, channel frontend
|
|
269
|
+
* (Established with the editor team, 2026-07-31, channel frontend↔framework.
|
|
270
270
|
* The editor shadows a foundation's `form` declaration with its own builder via
|
|
271
271
|
* `builtinSchemas()`; that is about the EDITING UI and is orthogonal to whether a
|
|
272
272
|
* foundation declares a schema for validation.)
|
package/src/setup.js
CHANGED
|
@@ -240,15 +240,17 @@ function buildDefaultFetcher(content) {
|
|
|
240
240
|
// serve data wherever it likes — including intercepting a site-local path and
|
|
241
241
|
// proxying it onward — with no framework change.
|
|
242
242
|
const basePath = content?.config?.base || import.meta.env?.BASE_URL || ''
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
243
|
+
// ⛔ `site.yml fetcher:` is NOT read here — the default fetcher takes no
|
|
244
|
+
// site-level vocabulary (retired 2026-09-04: baseUrl / headers / envelope /
|
|
245
|
+
// supports / request). The block is the site's SELECTION of foundation
|
|
246
|
+
// transports (`fetcher.transports`) plus a transport's own binding config,
|
|
247
|
+
// which a transport reads through ctx.website.config.fetcher.
|
|
248
|
+
//
|
|
247
249
|
// The host's live-records stamp (`config.records`), when a backend set one: the fetcher
|
|
248
250
|
// reads its `envelope` for requests that resolved to that lane.
|
|
249
251
|
const records = content?.config?.records ?? null
|
|
250
252
|
const dev = !!(import.meta.env && import.meta.env.DEV)
|
|
251
|
-
return createDefaultFetcher({ basePath,
|
|
253
|
+
return createDefaultFetcher({ basePath, dev, records })
|
|
252
254
|
}
|
|
253
255
|
|
|
254
256
|
/**
|
package/src/ssr.js
CHANGED
|
@@ -66,6 +66,12 @@ export {
|
|
|
66
66
|
prefetchPageData,
|
|
67
67
|
} from './prefetch.js'
|
|
68
68
|
|
|
69
|
+
// The composed render entry — resolve, render, inject, as one call. Built because
|
|
70
|
+
// framework itself had two callers of this unshared sequence (the build's prerender
|
|
71
|
+
// loop and an SSR isolate), not because a consumer asked; see page-renderer.js for
|
|
72
|
+
// what it deliberately leaves to the host.
|
|
73
|
+
export { createPageRenderer, prefetchAndHydrate } from './page-renderer.js'
|
|
74
|
+
|
|
69
75
|
// Appearance. injectPageContent() already emits this for every prerendered
|
|
70
76
|
// page; exported for lanes that assemble a shell without a per-page render.
|
|
71
77
|
export { renderAppearanceBootScript } from './appearance.js'
|
package/src/wire-foundation.js
CHANGED
|
@@ -166,7 +166,9 @@ export function hydrateDataStore(website, fetchedData) {
|
|
|
166
166
|
// A `prefetchPageData` list carries every declared config with an `outcome`; only what was
|
|
167
167
|
// actually fetched enters the store. A list without outcomes (the SSG lane's) is all fetched.
|
|
168
168
|
if (entry.outcome && entry.outcome !== 'fetched') continue
|
|
169
|
-
|
|
169
|
+
// `meta` (the depth the records were fetched at) rides along, so the store
|
|
170
|
+
// files them in its record index exactly as a runtime fetch would.
|
|
171
|
+
website.dataStore.set(deriveCacheKey(entry.config), entry.meta ? { data: entry.data, meta: entry.meta } : { data: entry.data })
|
|
170
172
|
}
|
|
171
173
|
}
|
|
172
174
|
|