@uniweb/runtime 0.28.4 → 0.28.6
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 +10 -3
- package/dist/ssr.js.map +1 -1
- package/package.json +3 -3
- package/src/components/PageRenderer.jsx +22 -41
- package/src/default-fetcher.js +3 -1
- package/src/isolate-api.js +6 -0
- package/src/page-renderer.js +20 -3
- package/src/ssr.js +12 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/runtime",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.6",
|
|
4
4
|
"description": "Minimal runtime for loading Uniweb foundations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"node": ">=20.19"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@uniweb/core": "^0.35.
|
|
39
|
+
"@uniweb/core": "^0.35.1",
|
|
40
40
|
"@uniweb/theming": "^0.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"esbuild": "^0.21.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.27.0",
|
|
45
45
|
"vite": "^7.3.1",
|
|
46
46
|
"vitest": "^4.1.7",
|
|
47
|
-
"@uniweb/build": "0.
|
|
47
|
+
"@uniweb/build": "0.74.2"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"react": "^19.0.0",
|
|
@@ -122,12 +122,14 @@ export default function PageRenderer() {
|
|
|
122
122
|
// Force re-render counter — incremented by DataStore listener below
|
|
123
123
|
const [, forceUpdate] = useReducer((x) => x + 1, 0)
|
|
124
124
|
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
125
|
+
// ⭐ WHAT THE URL NAMES — `Website#resolveRoute`, the one rule every lane calls
|
|
126
|
+
// (`@uniweb/core/resolve-route`): a page, a redirect, a page served from elsewhere, or
|
|
127
|
+
// nothing. Resolved synchronously (not in an effect) so child components like Header see the
|
|
128
|
+
// correct activePage during the same render cycle.
|
|
128
129
|
const navigate = useNavigate()
|
|
129
130
|
|
|
130
|
-
|
|
131
|
+
const resolution = website?.resolveRoute?.(location.pathname) ?? null
|
|
132
|
+
let page = resolution && resolution.kind !== 'notFound' ? resolution.page : null
|
|
131
133
|
if (page && website) website.setActivePage(location.pathname)
|
|
132
134
|
|
|
133
135
|
// ─── Content loading (split page content) ───
|
|
@@ -155,32 +157,16 @@ export default function PageRenderer() {
|
|
|
155
157
|
|
|
156
158
|
// ─── Compute navigation targets (before hooks, no early returns) ───
|
|
157
159
|
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
// The DECISION stays canonical — `getNavigableRoute()` and `page.route` are
|
|
166
|
-
// both canonical, and comparing a localized URL against a canonical route
|
|
167
|
-
// would make a folder-with-index redirect to itself forever.
|
|
168
|
-
const navigableRoute = page && !page.redirect && !page.hasContent()
|
|
169
|
-
? page.getNavigableRoute()
|
|
170
|
-
: null
|
|
171
|
-
const shouldAutoRedirect = !!(navigableRoute && navigableRoute !== page?.route)
|
|
172
|
-
|
|
173
|
-
// The DESTINATION is localized. `getNavigableRoute()` returns a canonical
|
|
174
|
-
// route, so navigating to it raw dropped both the slug translation and the
|
|
175
|
-
// `/<locale>` prefix: visiting `/fr/Guide-de-démarrage-rapide` landed on
|
|
176
|
-
// `/Quick-Start-Guide/From-Idea-to-Website` and served the page in ENGLISH.
|
|
177
|
-
// getLocaleUrl() is the same helper the language switcher uses.
|
|
178
|
-
const autoRedirectRoute = shouldAutoRedirect
|
|
179
|
-
? website?.getLocaleUrl?.(website.activeLocale, navigableRoute) || navigableRoute
|
|
180
|
-
: null
|
|
160
|
+
// The redirect the URL names, as the resolver gives it: an author's `redirect:` as written; a
|
|
161
|
+
// page with no content to its first descendant with content, as THIS locale shows it (the
|
|
162
|
+
// decision canonical, the destination localized — `localeUrl`, the language switcher's rule);
|
|
163
|
+
// or the locale served unprefixed, asked for with its prefix, to the path without it.
|
|
164
|
+
// ⛔ This component worked the first two out for itself until 2026-09-30, and the static build
|
|
165
|
+
// and every host each had a copy.
|
|
166
|
+
const redirectLocation = resolution?.kind === 'redirect' ? resolution.location : null
|
|
181
167
|
|
|
182
168
|
// If no page found, try the 404 page (do NOT fall back to activePage/homepage)
|
|
183
|
-
const isNotFound = !page && !
|
|
169
|
+
const isNotFound = !page && !redirectLocation
|
|
184
170
|
if (isNotFound) {
|
|
185
171
|
page = website?.getNotFoundPage?.() || null
|
|
186
172
|
}
|
|
@@ -194,18 +180,14 @@ export default function PageRenderer() {
|
|
|
194
180
|
// ─── All hooks called unconditionally (React rules of hooks) ───
|
|
195
181
|
|
|
196
182
|
useEffect(() => {
|
|
197
|
-
if (!
|
|
198
|
-
|
|
199
|
-
|
|
183
|
+
if (!redirectLocation) return
|
|
184
|
+
// A URL with a scheme leaves the app — another site, or a locale's own domain.
|
|
185
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(redirectLocation)) {
|
|
186
|
+
window.location.replace(redirectLocation)
|
|
200
187
|
} else {
|
|
201
|
-
navigate(
|
|
188
|
+
navigate(redirectLocation, { replace: true })
|
|
202
189
|
}
|
|
203
|
-
}, [
|
|
204
|
-
|
|
205
|
-
useEffect(() => {
|
|
206
|
-
if (!shouldAutoRedirect) return
|
|
207
|
-
navigate(autoRedirectRoute, { replace: true })
|
|
208
|
-
}, [shouldAutoRedirect, autoRedirectRoute, navigate])
|
|
190
|
+
}, [redirectLocation, navigate])
|
|
209
191
|
|
|
210
192
|
useHeadMeta(headMeta, { siteName })
|
|
211
193
|
|
|
@@ -221,13 +203,12 @@ export default function PageRenderer() {
|
|
|
221
203
|
|
|
222
204
|
// ─── Early returns (after all hooks) ───
|
|
223
205
|
|
|
224
|
-
if (
|
|
225
|
-
if (shouldAutoRedirect) return null
|
|
206
|
+
if (redirectLocation) return null
|
|
226
207
|
|
|
227
208
|
// Rewrite pages are served by an external site — the host handles routing.
|
|
228
209
|
// In SPA mode this shouldn't be reached (host proxies before JS loads),
|
|
229
210
|
// but if it is (e.g., dev mode), do a full page reload to let the host handle it.
|
|
230
|
-
if (
|
|
211
|
+
if (resolution?.kind === 'rewrite') {
|
|
231
212
|
window.location.reload()
|
|
232
213
|
return null
|
|
233
214
|
}
|
package/src/default-fetcher.js
CHANGED
|
@@ -252,7 +252,9 @@ export function createDefaultFetcher({ basePath = '', dev = false, fetch: fetchI
|
|
|
252
252
|
* Anything the service does not answer (a retired operator) is sent as written and
|
|
253
253
|
* answered there: never approximated.
|
|
254
254
|
*/
|
|
255
|
-
|
|
255
|
+
// Exported so the question a parametric page's record is asked with can be generated as test
|
|
256
|
+
// vectors: it is this body, and nothing else composes one.
|
|
257
|
+
export function toQuestion(request) {
|
|
256
258
|
const q = { schema: request.schema }
|
|
257
259
|
const where = request.where && typeof request.where === 'object' ? request.where : null
|
|
258
260
|
// ⭐ `scope` crosses as authored. ⛔ A top-level `where.path.under` was respelled
|
package/src/isolate-api.js
CHANGED
|
@@ -106,6 +106,12 @@ export const ISOLATE_API = Object.freeze({
|
|
|
106
106
|
prefetchAndHydrate: '0.14.2',
|
|
107
107
|
// the whole corpus, for a host that indexes rather than renders
|
|
108
108
|
collectSiteRecords: '0.17.0',
|
|
109
|
+
// what a URL names — a page, a redirect, a page served from elsewhere, or nothing
|
|
110
|
+
resolveRoute: UNRELEASED,
|
|
111
|
+
routeIndex: UNRELEASED,
|
|
112
|
+
localeOfPath: UNRELEASED,
|
|
113
|
+
localeUrl: UNRELEASED,
|
|
114
|
+
RESOLUTION_STATUS: UNRELEASED,
|
|
109
115
|
})
|
|
110
116
|
|
|
111
117
|
/**
|
package/src/page-renderer.js
CHANGED
|
@@ -65,6 +65,13 @@ export function createPageRenderer({ website, shell }) {
|
|
|
65
65
|
* @param {Object} [options.inject] - extra options forwarded to `injectPageContent`
|
|
66
66
|
* @returns {{ outcome: 'rendered'|'notFound'|'failed', html: string|null,
|
|
67
67
|
* page: Object|null, error: {type: string, message: string}|null }}
|
|
68
|
+
* `notFound` means the URL names nothing, and `html` says whether there is a page to
|
|
69
|
+
* send for it:
|
|
70
|
+
* - `page: null`, `html: null` — no route matched. Send your own not-found page.
|
|
71
|
+
* - `page` set, `html` set — the page's record does not exist, and `html` is the page
|
|
72
|
+
* rendered in its own not-found state. Send it, with your not-found status.
|
|
73
|
+
* - `page` set, `html: null`, `error` set — that not-found state broke while rendering.
|
|
74
|
+
* Send your own not-found page; `error` says what broke.
|
|
68
75
|
*/
|
|
69
76
|
function render(target, { inject = {} } = {}) {
|
|
70
77
|
const resolved = typeof target === 'string' ? resolvePage(website, target) : target
|
|
@@ -86,6 +93,16 @@ export function createPageRenderer({ website, shell }) {
|
|
|
86
93
|
// you", not throw.
|
|
87
94
|
const page = typeof resolved.getRenderableSelf === 'function' ? resolved.getRenderableSelf() : resolved
|
|
88
95
|
|
|
96
|
+
// ⭐ A PAGE CAN RESOLVE AND STILL NAME NOTHING: a parametric page whose record does
|
|
97
|
+
// not exist. Core marks it `notFound` only when the data answered definitively
|
|
98
|
+
// (`Website#_createDynamicPage`), never on a failed or missing fetch. It renders the
|
|
99
|
+
// page's own not-found state — a record miss is not a route miss — and the outcome
|
|
100
|
+
// says `notFound` all the same, so a caller keying its status on the outcome sends a
|
|
101
|
+
// not-found response without reading anything new. Known before rendering, so it
|
|
102
|
+
// holds even when that render breaks. ⛔ Until 2026-09-30 this was `rendered`, and a
|
|
103
|
+
// host that answered by the outcome sent 200 for a URL naming no record.
|
|
104
|
+
const missing = page?.notFound === true
|
|
105
|
+
|
|
89
106
|
let result
|
|
90
107
|
try {
|
|
91
108
|
result = renderPage(page, website)
|
|
@@ -93,10 +110,10 @@ export function createPageRenderer({ website, shell }) {
|
|
|
93
110
|
// `renderPage` handles its own errors, but a foundation can throw from
|
|
94
111
|
// module scope in ways it does not catch. Classify rather than propagate,
|
|
95
112
|
// so one page cannot take down a build loop or an isolate's request.
|
|
96
|
-
return { outcome: 'failed', html: null, page, error: classifyRenderError(err) }
|
|
113
|
+
return { outcome: missing ? 'notFound' : 'failed', html: null, page, error: classifyRenderError(err) }
|
|
97
114
|
}
|
|
98
115
|
|
|
99
|
-
if (result.error) return { outcome: 'failed', html: null, page, error: result.error }
|
|
116
|
+
if (result.error) return { outcome: missing ? 'notFound' : 'failed', html: null, page, error: result.error }
|
|
100
117
|
|
|
101
118
|
// ⛔ `sectionOverrideCSS` LAST, so a caller's `inject` cannot displace it. It is
|
|
102
119
|
// computed by `renderPage` for this page — theme pinning and component vars —
|
|
@@ -108,7 +125,7 @@ export function createPageRenderer({ website, shell }) {
|
|
|
108
125
|
...inject,
|
|
109
126
|
sectionOverrideCSS: result.sectionOverrideCSS,
|
|
110
127
|
})
|
|
111
|
-
return { outcome: 'rendered', html, page, error: null }
|
|
128
|
+
return { outcome: missing ? 'notFound' : 'rendered', html, page, error: null }
|
|
112
129
|
}
|
|
113
130
|
|
|
114
131
|
return { website, render }
|
package/src/ssr.js
CHANGED
|
@@ -63,10 +63,18 @@ export {
|
|
|
63
63
|
// hydration.
|
|
64
64
|
export { loadPageData } from './load-page-data.js'
|
|
65
65
|
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
66
|
+
// ⭐ WHAT A URL NAMES — a page, a redirect, a page served from elsewhere, or nothing: the rule the
|
|
67
|
+
// SPA and the static build call (`@uniweb/core/resolve-route`). A host asks it BEFORE it fetches:
|
|
68
|
+
// a redirect needs no data and a URL naming nothing needs no render. It reads one locale's content;
|
|
69
|
+
// `localeOfPath` says which locale a path names, so which content to read. `status` is what a host
|
|
70
|
+
// that sends one sends. ⛔ Each host kept its own copy of the redirect and locale rules until
|
|
71
|
+
// 2026-09-30, and the copies disagreed.
|
|
72
|
+
export { resolveRoute, routeIndex, localeOfPath, localeUrl, RESOLUTION_STATUS } from '@uniweb/core/resolve-route'
|
|
73
|
+
|
|
74
|
+
// Which page a route names, by its exact route and then the parametric patterns
|
|
75
|
+
// (`@uniweb/core/route-match`). ⚠️ The subset of `resolveRoute`: no locale prefix, no translated
|
|
76
|
+
// route, no index page — for a canonical, unprefixed route only. A copy that compared strings made
|
|
77
|
+
// `/about/` prefetch nothing and then render fine (2026-09-12).
|
|
70
78
|
// ⛔ `prefetchPageData`, `resolvePageFetchConfigs` and `executeFetchConfigs` are GONE (2026-09-20).
|
|
71
79
|
// They worked from the payload alone, which cannot say which keys a component declares, which
|
|
72
80
|
// layout a page draws, or which transport a key is routed to — four measured cases wrong
|