@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "0.28.4",
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.0",
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.73.3"
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
- // 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,16 @@ 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:` 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 && !redirectTarget
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 (!redirectTarget) return
198
- if (redirectTarget.startsWith('http')) {
199
- window.location.replace(redirectTarget)
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(redirectTarget, { replace: true })
188
+ navigate(redirectLocation, { replace: true })
202
189
  }
203
- }, [redirectTarget, navigate])
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 (redirectTarget) return null
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 (page?.rewrite) {
211
+ if (resolution?.kind === 'rewrite') {
231
212
  window.location.reload()
232
213
  return null
233
214
  }
@@ -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
@@ -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
  /**
@@ -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
- // 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