@uniweb/runtime 0.28.5 → 0.28.7

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/runtime",
3
- "version": "0.28.5",
3
+ "version": "0.28.7",
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.0",
39
+ "@uniweb/core": "^0.35.2",
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.74.0"
47
+ "@uniweb/build": "0.74.3"
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
- // Resolve page from current URL and sync website.activePage immediately.
126
- // This must happen synchronously (not in an effect) so child components
127
- // like Header see the correct activePage during the same render cycle.
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
- let page = website?.getPage(location.pathname)
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,25 @@ export default function PageRenderer() {
155
157
 
156
158
  // ─── Compute navigation targets (before hooks, no early returns) ───
157
159
 
158
- // Explicit redirect: in page.yml
159
- const redirectTarget = page?.redirect || null
160
-
161
- // Auto-redirect for content-less pages (structural containers).
162
- // Folders with page.yml but no markdown keep their route in the hierarchy;
163
- // when visited directly, redirect to the first descendant with content.
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:` — a page of the
161
+ // site as THIS locale shows it, anything else as written; a page with no content to its first
162
+ // descendant with content, in this locale too (the decision canonical, the destination localized —
163
+ // `localeUrl`, the language switcher's rule); or the locale served unprefixed, asked for with its
164
+ // prefix, to the path without it.
165
+ // ⛔ This component worked the first two out for itself until 2026-09-30, and the static build
166
+ // and every host each had a copy.
167
+ const redirectLocation = resolution?.kind === 'redirect' ? resolution.location : null
168
+
169
+ // ⭐ A page served from elsewhere (`rewrite:`) is the HOST's to proxy, before the app loads.
170
+ // Reached by in-app navigation, it is loaded for real so the host can. Reached as the document's
171
+ // own first load (`location.key === 'default'`), the host served the app instead — it does not
172
+ // proxy — so there is no page here: the not-found page, as a static build emits none for it.
173
+ // ⛔ Until 2026-09-30 both reloaded, and on a host that does not proxy that reload never ended.
174
+ const isRewrite = resolution?.kind === 'rewrite'
175
+ const rewriteNotProxied = isRewrite && location.key === 'default'
181
176
 
182
177
  // If no page found, try the 404 page (do NOT fall back to activePage/homepage)
183
- const isNotFound = !page && !redirectTarget
178
+ const isNotFound = (!page && !redirectLocation) || rewriteNotProxied
184
179
  if (isNotFound) {
185
180
  page = website?.getNotFoundPage?.() || null
186
181
  }
@@ -194,18 +189,14 @@ export default function PageRenderer() {
194
189
  // ─── All hooks called unconditionally (React rules of hooks) ───
195
190
 
196
191
  useEffect(() => {
197
- if (!redirectTarget) return
198
- if (redirectTarget.startsWith('http')) {
199
- window.location.replace(redirectTarget)
192
+ if (!redirectLocation) return
193
+ // A URL with a scheme leaves the app — another site, or a locale's own domain.
194
+ if (/^[a-z][a-z0-9+.-]*:/i.test(redirectLocation)) {
195
+ window.location.replace(redirectLocation)
200
196
  } else {
201
- navigate(redirectTarget, { replace: true })
197
+ navigate(redirectLocation, { replace: true })
202
198
  }
203
- }, [redirectTarget, navigate])
204
-
205
- useEffect(() => {
206
- if (!shouldAutoRedirect) return
207
- navigate(autoRedirectRoute, { replace: true })
208
- }, [shouldAutoRedirect, autoRedirectRoute, navigate])
199
+ }, [redirectLocation, navigate])
209
200
 
210
201
  useHeadMeta(headMeta, { siteName })
211
202
 
@@ -221,13 +212,10 @@ export default function PageRenderer() {
221
212
 
222
213
  // ─── Early returns (after all hooks) ───
223
214
 
224
- if (redirectTarget) return null
225
- if (shouldAutoRedirect) return null
215
+ if (redirectLocation) return null
226
216
 
227
- // Rewrite pages are served by an external site — the host handles routing.
228
- // In SPA mode this shouldn't be reached (host proxies before JS loads),
229
- // but if it is (e.g., dev mode), do a full page reload to let the host handle it.
230
- if (page?.rewrite) {
217
+ // An in-app navigation to a rewrite route: load it for real, so a host that proxies can.
218
+ if (isRewrite && !rewriteNotProxied) {
231
219
  window.location.reload()
232
220
  return null
233
221
  }
@@ -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
- function toQuestion(request) {
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
@@ -5,11 +5,13 @@
5
5
  * ⭐ THE ONE PLACE THE NUMBER IS STATED. A host loads the SITE'S pinned runtime as a
6
6
  * dynamically-loaded artifact — never an import — so it cannot link-check what it
7
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
8
+ * runtime. The backend evaluates a site's runtime at publish against this floor [Diego,
9
+ * 2026-09-04: "We will set a runtime version floor that guarantees they are there"],
10
+ * composing it as `max(floor, foundationFloors)`. It reads the floor from the runtime
11
+ * channel's index (`minUsable`), which every channel publish writes from this file —
12
+ * since 2026-09-05, when that replaced a constant copied from here by hand. (This said
13
+ * the backend held a copied constant until 2026-09-30.) `tests/isolate-api.test.js` is
14
+ * what keeps this file honest: every export of `src/ssr.js` must appear below with the version
13
15
  * it first shipped in, and every name below must still be exported — by the source
14
16
  * and by the built `dist/ssr.js` when it is present. Forgetting to stamp a new export
15
17
  * fails HERE, in the repo where the change happens; a rename fails here too.
@@ -57,7 +59,9 @@
57
59
  * 3. replace `UNRELEASED` with the version that publish produced — the floor
58
60
  * moves here, and the runtime channel's `minUsable` follows at the
59
61
  * next channel publish;
60
- * 4. tell backend, which holds the number and must ratchet it.
62
+ * 4. nothing to copy by hand: the backend reads the floor from the channel index, so
63
+ * a raise reaches it at the channel publish AFTER the commit that makes it. (This
64
+ * step said "tell backend, which holds the number" until 2026-09-30.)
61
65
  *
62
66
  * ⚖️ **Step 3 is a real obligation, not bookkeeping**: an export left
63
67
  * `UNRELEASED` after it ships keeps the floor below its own API forever, so a
@@ -106,6 +110,12 @@ export const ISOLATE_API = Object.freeze({
106
110
  prefetchAndHydrate: '0.14.2',
107
111
  // the whole corpus, for a host that indexes rather than renders
108
112
  collectSiteRecords: '0.17.0',
113
+ // what a URL names — a page, a redirect, a page served from elsewhere, or nothing
114
+ resolveRoute: '0.28.6',
115
+ routeIndex: '0.28.6',
116
+ localeOfPath: '0.28.6',
117
+ localeUrl: '0.28.6',
118
+ RESOLUTION_STATUS: '0.28.6',
109
119
  })
110
120
 
111
121
  /**
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
- // Which page a route names — the rule, not a copy of it (`@uniweb/core/route-match`). A host
67
- // resolving a route before it asks us anything imports this rather than comparing strings: the
68
- // SPA normalizes a trailing slash, and a copy that did not made `/about/` prefetch nothing and
69
- // then render fine (2026-09-12).
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