@uniweb/runtime 0.14.2 → 0.16.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.
@@ -0,0 +1,87 @@
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, because a consumer that bundles this package by workspace link
21
+ * gets the change at commit time, with no version to pin against.
22
+ *
23
+ * ⛔ Not `runtime-pin.json`. That file is emitted per FOUNDATION build and records an
24
+ * observed fact ("built against"), never a guarantee; an isolate-API floor is a
25
+ * guarantee and is not a property of any foundation. Different kind of claim,
26
+ * different home.
27
+ *
28
+ * "since" is the first PUBLISHED version (git tag) whose `@uniweb/runtime/ssr`
29
+ * exported the name — measured with `git log --reverse -S<name> -- src/ssr.js` and
30
+ * `git tag --contains`, 2026-09-04.
31
+ */
32
+
33
+ /** Every export of `@uniweb/runtime/ssr`, with the version it first shipped in. */
34
+ export const ISOLATE_API = Object.freeze({
35
+ // props preparation
36
+ prepareProps: '0.2.15',
37
+ applySchemas: '0.2.15',
38
+ applyDefaults: '0.2.15',
39
+ guaranteeContentStructure: '0.2.15',
40
+ getComponentMeta: '0.2.15',
41
+ getComponentDefaults: '0.2.15',
42
+ // rendering
43
+ getWrapperProps: '0.6.14',
44
+ renderBackground: '0.6.14',
45
+ renderBlock: '0.6.14',
46
+ renderBlocks: '0.6.14',
47
+ renderLayout: '0.6.14',
48
+ renderPage: '0.2.15',
49
+ classifyRenderError: '0.6.14',
50
+ injectPageContent: '0.6.14',
51
+ escapeHtml: '0.6.14',
52
+ generate404Html: '0.6.16',
53
+ // initialization
54
+ initPrerender: '0.6.14',
55
+ initPrerenderForLocale: '0.8.9',
56
+ sliceContentForLocale: '0.8.9',
57
+ hydrateDataStore: '0.8.9',
58
+ prefetchIcons: '0.6.14',
59
+ renderAppearanceBootScript: '0.8.30',
60
+ // page resolution
61
+ resolvePage: '0.9.5',
62
+ // server-side prefetch — the runtime executing a page's fetches for a host
63
+ findPageForRoute: '0.14.1',
64
+ resolvePageFetchConfigs: '0.14.1',
65
+ executeFetchConfigs: '0.14.1',
66
+ prefetchPageData: '0.14.1',
67
+ // the composed render entry
68
+ createPageRenderer: '0.14.2',
69
+ prefetchAndHydrate: '0.14.2',
70
+ })
71
+
72
+ /**
73
+ * The runtime version at or above which EVERY name in `ISOLATE_API` is exported —
74
+ * the absolute floor a host may rely on with no feature detection.
75
+ */
76
+ export const ISOLATE_API_FLOOR = Object.values(ISOLATE_API).reduce((max, v) => (compareVersions(v, max) > 0 ? v : max), '0.0.0')
77
+
78
+ /** Compare two `x.y.z` versions numerically. Returns <0, 0 or >0. */
79
+ export function compareVersions(a, b) {
80
+ const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0)
81
+ const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0)
82
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
83
+ const d = (pa[i] || 0) - (pb[i] || 0)
84
+ if (d !== 0) return d
85
+ }
86
+ return 0
87
+ }
@@ -18,8 +18,8 @@
18
18
  *
19
19
  * - **Shell assembly.** The shell arrives built. The import map, the CDN base and
20
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*).
21
+ * modelling a deployment it cannot see (the host drew this line itself,
22
+ * 2026-09-03: a serve location is read from the payload, never constructed here).
23
23
  * - **Init and hydration.** The two lanes differ REALLY here, not incidentally: a
24
24
  * build initializes once and hydrates every collection up front, an isolate
25
25
  * initializes per locale and prefetches per route. Folding either in would fit
package/src/prefetch.js CHANGED
@@ -15,7 +15,7 @@
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`, `config.fetcher`, `config.base` are read from it.
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
@@ -37,7 +37,7 @@
37
37
  * (`build/src/prerender.js`) and never calls this. `'author'` is the explicit opt-in
38
38
  * for a caller that bakes; omitting the option must not silently reproduce the
39
39
  * 2026-07-28 outcome — prefetch a no-op on a live-data template, page still 200.
40
- * - returns one entry per DECLARED config, `{ config, outcome, data, error? }`, keyed
40
+ * - returns one entry per DECLARED config, `{ config, outcome, data, meta?, error? }`, keyed
41
41
  * downstream by `deriveCacheKey(config)`. `outcome` is `fetched`, `failed`
42
42
  * (transport or HTTP error, `error` says which) or `skipped` (the author
43
43
  * deferred it to the browser with `prerender: false`). `hydrateDataStore`
@@ -46,17 +46,23 @@
46
46
  * is a different cache decision (hosting, 2026-09-03).
47
47
  *
48
48
  * It resolves nothing the host owns and models no host route layout: every address is
49
- * `{base}/…` from the payload, or an endpoint the host itself published in `config.records`.
49
+ * `{base}/…` from the payload, or the question door the host itself published at
50
+ * `config.records.query`.
50
51
  */
51
52
  import { resolveFetchConfigs } from '@uniweb/core/fetch-config'
52
53
  import { deriveCacheKey } from '@uniweb/core/datastore'
53
- import { routePatternToRegex } from '@uniweb/core/route-match'
54
+ import { routePatternToRegex, decodeRouteValue, splitPathCapture } from '@uniweb/core/route-match'
55
+ import { buildDetailConfig } from '@uniweb/core/detail-url'
54
56
  import { resolveDefaultLocale } from '@uniweb/core/locale-config'
55
57
  import { createDefaultFetcher } from './default-fetcher.js'
56
58
 
57
59
  const isRefinement = (f) => f && typeof f === 'object' && f.refine === true
58
60
 
59
- /** The page a route names — exact first, then the `[slug]` templates, like the SPA. */
61
+ /**
62
+ * The page a route names — exact first, then the `[slug]` / `[...path]` templates, like
63
+ * the SPA. Captured params are decoded the way `matchDynamicRoute` decodes them (a
64
+ * catch-all per segment), so the values are what the site's query is bound against.
65
+ */
60
66
  export function findPageForRoute(content, route) {
61
67
  const pages = content?.pages || []
62
68
  const exact = pages.find((p) => p.route === route)
@@ -65,11 +71,35 @@ export function findPageForRoute(content, route) {
65
71
  if (!page.isDynamic || !page.route) continue
66
72
  const compiled = routePatternToRegex(page.route)
67
73
  const m = compiled?.regex ? compiled.regex.exec(route) : null
68
- if (m) return { page, params: Object.fromEntries((compiled.paramNames || []).map((n, i) => [n, m[i + 1]])) }
74
+ if (m) {
75
+ const params = {}
76
+ ;(compiled.paramNames || []).forEach((n, i) => {
77
+ const raw = m[i + 1]
78
+ params[n] = n === compiled.catchAll ? raw.split('/').map(decodeRouteValue).join('/') : decodeRouteValue(raw)
79
+ })
80
+ return { page, params }
81
+ }
69
82
  }
70
83
  return { page: null, params: {} }
71
84
  }
72
85
 
86
+ /**
87
+ * The route's variables and the delivery param for a matched template page — the same
88
+ * binding the SPA makes in `Website._createDynamicPage`: `[slug]` binds the one capture
89
+ * under the folder's own name; `[...path]` splits its capture into `path` / `dir` /
90
+ * `slug` and delivers by `slug`, the record's handle.
91
+ */
92
+ function routeBinding(page, params) {
93
+ const { catchAll } = routePatternToRegex(page.route)
94
+ if (catchAll && params[catchAll] !== undefined) {
95
+ const parts = splitPathCapture(params[catchAll])
96
+ const paramName = page.paramName || 'slug'
97
+ return { paramName, paramValue: parts.slug, variables: { ...params, ...parts } }
98
+ }
99
+ const paramName = page.paramName || Object.keys(params)[0]
100
+ return { paramName, paramValue: params[paramName], variables: { ...params } }
101
+ }
102
+
73
103
  /**
74
104
  * Every fetch config a page will need at render time, resolved once and de-duplicated by
75
105
  * cache key: the site-level fetch, the page's, its parent's, and each section's own
@@ -79,15 +109,17 @@ export function findPageForRoute(content, route) {
79
109
  * @returns {Object[]} resolved fetch configs
80
110
  */
81
111
  export function resolvePageFetchConfigs(content, route, { locale = null } = {}) {
82
- const { page } = findPageForRoute(content, route)
112
+ const { page, params } = findPageForRoute(content, route)
83
113
  if (!page) return []
84
114
  const pages = content?.pages || []
85
115
  const parent = page.parent ? pages.find((p) => p.route === page.parent) : null
116
+ const binding = page.isDynamic && Object.keys(params).length ? routeBinding(page, params) : null
86
117
  const options = {
87
118
  locale,
88
119
  defaultLocale: resolveDefaultLocale(content?.config) ?? null,
89
120
  queries: content?.config?.queries ?? null,
90
121
  records: content?.config?.records ?? null,
122
+ variables: binding?.variables ?? null,
91
123
  }
92
124
  const out = new Map()
93
125
  const add = (sources) => {
@@ -105,6 +137,23 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
105
137
  }
106
138
  }
107
139
  walk(page.sections)
140
+
141
+ // ⭐ A template page is ABOUT one record, and the record is a fetch of its own.
142
+ // The list the page inherits is what the entity store matches the route param
143
+ // against; when that query has a per-record source (a live lane's record address,
144
+ // a `deferred:` query's per-record file), the record itself comes from a second
145
+ // request — which this helper never built, so a host prerendering a template page
146
+ // got the BRIEF and the body arrived after hydration as a client fetch. The
147
+ // detail config is built by the one rule the
148
+ // entity store uses (`buildDetailConfig`), keyed by the route's param.
149
+ if (binding && binding.paramValue !== undefined && page.parentSchema) {
150
+ const listCfg = [...out.values()].find((cfg) => cfg.as === page.parentSchema && cfg.detail)
151
+ const detailCfg = listCfg ? buildDetailConfig(listCfg, { paramName: binding.paramName, paramValue: String(binding.paramValue) }) : null
152
+ if (detailCfg) {
153
+ const key = deriveCacheKey(detailCfg)
154
+ if (!out.has(key)) out.set(key, detailCfg)
155
+ }
156
+ }
108
157
  return [...out.values()]
109
158
  }
110
159
 
@@ -114,7 +163,7 @@ export function resolvePageFetchConfigs(content, route, { locale = null } = {})
114
163
  * @param {Object[]} configs resolved configs (from `resolvePageFetchConfigs` or the host's own
115
164
  * call to `resolveFetchConfigs`)
116
165
  * @param {Object} opts
117
- * @param {Object} opts.content the payload — `config.base`, `config.fetcher`, `config.records`
166
+ * @param {Object} opts.content the payload — `config.base`, `config.records`
118
167
  * @param {Function} [opts.fetch] the transport; defaults to the global `fetch`
119
168
  * @param {boolean} [opts.dev]
120
169
  * @returns {Promise<Array<{ config: Object, outcome: 'fetched'|'failed'|'skipped', data: any, error?: string }>>}
@@ -125,26 +174,23 @@ export async function executeFetchConfigs(configs, { content, fetch = null, dev
125
174
  }
126
175
  const fetcher = createDefaultFetcher({
127
176
  basePath: content?.config?.base || '',
128
- config: content?.config?.fetcher ?? {},
129
- records: content?.config?.records ?? null,
130
177
  dev,
131
178
  fetch,
132
179
  })
133
180
  const ctx = { website: null }
134
- const out = []
135
- for (const config of configs || []) {
136
- if (!config) continue
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) => {
137
185
  if (prerender === 'author' && config.prerender === false) {
138
186
  // The author deferred this one to the browser and the caller honours that. Present, so a
139
187
  // host can count what was declared against what was tried; not hydrated.
140
- out.push({ config, outcome: 'skipped', data: null })
141
- continue
188
+ return { config, outcome: 'skipped', data: null }
142
189
  }
143
190
  const result = await fetcher.resolve(config, ctx)
144
- if (result?.error) out.push({ config, outcome: 'failed', data: null, error: result.error })
145
- else out.push({ config, outcome: 'fetched', data: result?.data ?? null })
146
- }
147
- 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
+ }))
148
194
  }
149
195
 
150
196
  /** Resolve and execute in one call: what a host passes the isolate as `fetchedData`. */
@@ -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-framework-066d.
269
+ * (Established with the editor team, 2026-07-31, channel frontendframework.
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,14 @@ 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
- // Per-site transport config from `site.yml fetcher:`. The default fetcher
244
- // recognizes `baseUrl` and `envelope`; foundations with their own fetchers
245
- // may read additional keys from the same block via ctx.website.config.fetcher.
246
- const config = content?.config?.fetcher ?? {}
247
- // The host's live-records stamp (`config.records`), when a backend set one: the fetcher
248
- // reads its `envelope` for requests that resolved to that lane.
249
- const records = content?.config?.records ?? null
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
+ //
250
249
  const dev = !!(import.meta.env && import.meta.env.DEV)
251
- return createDefaultFetcher({ basePath, config, dev, records })
250
+ return createDefaultFetcher({ basePath, dev })
252
251
  }
253
252
 
254
253
  /**
@@ -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
- website.dataStore.set(deriveCacheKey(entry.config), { data: entry.data })
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