@bitstillery/mithril 3.0.0-AA

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/api/mount-redraw.ts +173 -0
  4. package/api/router-server.ts +88 -0
  5. package/api/router.ts +670 -0
  6. package/browser.ts +10 -0
  7. package/htm.ts +7 -0
  8. package/hyperscript.ts +8 -0
  9. package/index.ts +134 -0
  10. package/jsx.d.ts +16 -0
  11. package/mount.ts +10 -0
  12. package/package.json +66 -0
  13. package/pathname/build.ts +40 -0
  14. package/pathname/compileTemplate.ts +49 -0
  15. package/pathname/parse.ts +19 -0
  16. package/querystring/build.ts +22 -0
  17. package/querystring/parse.ts +50 -0
  18. package/redraw.ts +10 -0
  19. package/render/cachedAttrsIsStaticMap.ts +10 -0
  20. package/render/delayedRemoval.ts +1 -0
  21. package/render/domFor.ts +23 -0
  22. package/render/emptyAttrs.ts +2 -0
  23. package/render/fragment.ts +11 -0
  24. package/render/hyperscript.ts +125 -0
  25. package/render/hyperscriptVnode.ts +21 -0
  26. package/render/render.ts +1307 -0
  27. package/render/renderToString.ts +494 -0
  28. package/render/ssrState.ts +366 -0
  29. package/render/trust.ts +6 -0
  30. package/render/vnode.ts +131 -0
  31. package/route.ts +11 -0
  32. package/server/logger.ts +223 -0
  33. package/server/session.ts +87 -0
  34. package/server/ssr.ts +205 -0
  35. package/server/ssrLogger.ts +13 -0
  36. package/server.ts +101 -0
  37. package/signal.ts +320 -0
  38. package/ssrContext.ts +87 -0
  39. package/state.ts +1026 -0
  40. package/store.ts +457 -0
  41. package/util/censor.ts +46 -0
  42. package/util/decodeURIComponentSafe.ts +34 -0
  43. package/util/hasOwn.ts +2 -0
  44. package/util/next_tick.ts +45 -0
  45. package/util/ssr.ts +385 -0
  46. package/util/uri.ts +126 -0
  47. package/version.ts +3 -0
package/api/router.ts ADDED
@@ -0,0 +1,670 @@
1
+ import Vnode from '../render/vnode'
2
+ import hyperscript from '../render/hyperscript'
3
+ import decodeURIComponentSafe from '../util/decodeURIComponentSafe'
4
+ import buildPathname from '../pathname/build'
5
+ import parsePathname from '../pathname/parse'
6
+ import compileTemplate from '../pathname/compileTemplate'
7
+ import censor from '../util/censor'
8
+ import {getPathname, getSearch, getHash} from '../util/uri'
9
+ import {logger} from '../server/ssrLogger'
10
+
11
+ import type {ComponentType, Vnode as VnodeType} from '../render/vnode'
12
+
13
+ // RedirectObject will be defined after REDIRECT symbol is created
14
+ // Using a type that references the symbol indirectly
15
+ export type RedirectObject = {[key: symbol]: string}
16
+
17
+ export interface RouteResolver<Attrs = Record<string, any>, State = any> {
18
+ onmatch?: (
19
+ args: Attrs,
20
+ requestedPath: string,
21
+ route: string,
22
+ ) => ComponentType<Attrs, State> | Promise<ComponentType<Attrs, State>> | RedirectObject | Promise<RedirectObject> | void
23
+ render?: (vnode: VnodeType<Attrs, State>) => VnodeType
24
+ }
25
+
26
+ export type SSRState = Record<string, any>
27
+ export type SSRResult = string | {html: string; state: SSRState}
28
+
29
+ export interface Route {
30
+ (path: string, params?: Record<string, any>, shouldReplaceHistory?: boolean): void
31
+ (path: string, component: ComponentType, shouldReplaceHistory?: boolean): void
32
+ set: (path: string, params?: Record<string, any>, data?: any) => void
33
+ get: () => string
34
+ prefix: string
35
+ link: (vnode: VnodeType) => string
36
+ param: (key?: string) => any
37
+ params: Record<string, any>
38
+ Link: ComponentType
39
+ SKIP: {}
40
+ REDIRECT: symbol
41
+ redirect: (path: string) => RedirectObject
42
+ resolve: (
43
+ pathname: string,
44
+ routes: Record<string, ComponentType | RouteResolver | {component: ComponentType | RouteResolver}>,
45
+ renderToString: (vnodes: any) => Promise<SSRResult>,
46
+ prefix?: string,
47
+ ) => Promise<SSRResult>
48
+ }
49
+
50
+ interface MountRedraw {
51
+ mount: (root: Element, component: ComponentType | null) => void
52
+ redraw: () => void
53
+ }
54
+
55
+ interface RouteOptions {
56
+ replace?: boolean
57
+ state?: any
58
+ title?: string | null
59
+ }
60
+
61
+ export default function router($window: any, mountRedraw: MountRedraw) {
62
+ let p = Promise.resolve()
63
+
64
+ let scheduled = false
65
+
66
+ let ready = false
67
+ let hasBeenResolved = false
68
+
69
+ let dom: Element | undefined
70
+ let compiled:
71
+ | Array<{route: string; component: any; check: (data: {path: string; params: Record<string, any>}) => boolean}>
72
+ | undefined
73
+ let fallbackRoute: string | undefined
74
+
75
+ let currentResolver: RouteResolver | null = null
76
+ let component: ComponentType | string = 'div'
77
+ let attrs: Record<string, any> = {}
78
+ let currentPath: string | undefined
79
+ let lastUpdate: ((comp: any) => void) | null = null
80
+
81
+ const RouterRoot: ComponentType = {
82
+ onremove: function () {
83
+ ready = hasBeenResolved = false
84
+ $window.removeEventListener('popstate', fireAsync, false)
85
+ },
86
+ view: function () {
87
+ // The route has already been resolved.
88
+ // Therefore, the following early return is not needed.
89
+ // if (!hasBeenResolved) return
90
+
91
+ // Pass currentPath in attrs so RouteResolver.render can use it for routePath.
92
+ // Use attrs.key (not currentPath) so that route param changes within the same
93
+ // route pattern update the component instead of causing a full remount.
94
+ const routeAttrs = {...attrs, routePath: currentPath || attrs.routePath}
95
+ const vnode = Vnode(component, attrs.key, routeAttrs, null, null, null)
96
+ if (currentResolver) return currentResolver.render!(vnode as any)
97
+ // Wrap in a fragment to preserve existing key semantics
98
+ return [vnode]
99
+ },
100
+ }
101
+
102
+ const SKIP = (route.SKIP = {})
103
+
104
+ // Redirect symbol for isomorphic redirect handling
105
+ const REDIRECT = (route.REDIRECT = Symbol('REDIRECT'))
106
+
107
+ // Helper function to create redirect objects
108
+ route.redirect = function (path: string) {
109
+ return {[REDIRECT]: path} as RedirectObject
110
+ }
111
+
112
+ // Type guard to check if value is a redirect object
113
+ // Note: We check for any Symbol key that might be a redirect, not just our specific REDIRECT symbol
114
+ // This allows redirect objects created by different router instances to be detected
115
+ function isRedirect(value: any): value is RedirectObject {
116
+ if (value == null || typeof value !== 'object') return false
117
+ // Check if this object has our REDIRECT symbol
118
+ if (REDIRECT in value) return true
119
+ // Also check for any Symbol keys that might be redirect objects from other router instances
120
+ // This handles the case where redirect objects are created by client-side m.route.redirect
121
+ // but checked by server-side router (or vice versa)
122
+ const symbolKeys = Object.getOwnPropertySymbols(value)
123
+ if (symbolKeys.length > 0) {
124
+ // Check if any symbol key's description suggests it's a redirect
125
+ // Or check if the object has a string property that looks like a path
126
+ for (const sym of symbolKeys) {
127
+ const desc = sym.description || ''
128
+ if (desc.includes('REDIRECT') || desc === 'REDIRECT') {
129
+ const path = value[sym]
130
+ if (typeof path === 'string' && path.startsWith('/')) {
131
+ return true
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return false
137
+ }
138
+
139
+ // Helper to extract redirect path from redirect object (handles different REDIRECT symbols)
140
+ function getRedirectPath(redirectObj: RedirectObject): string {
141
+ // First try our REDIRECT symbol
142
+ if (REDIRECT in redirectObj) {
143
+ return redirectObj[REDIRECT]
144
+ }
145
+ // Otherwise, check all symbol keys
146
+ const symbolKeys = Object.getOwnPropertySymbols(redirectObj)
147
+ for (const sym of symbolKeys) {
148
+ const path = redirectObj[sym]
149
+ if (typeof path === 'string' && path.startsWith('/')) {
150
+ return path
151
+ }
152
+ }
153
+ throw new Error('Invalid redirect object: no redirect path found')
154
+ }
155
+
156
+ function resolveRoute() {
157
+ scheduled = false
158
+ // Consider the pathname holistically. The prefix might even be invalid,
159
+ // but that's not our problem.
160
+ // Use isomorphic URI API unconditionally - it handles environment detection internally
161
+ const hash = getHash()
162
+ let prefix = hash
163
+ if (route.prefix[0] !== '#') {
164
+ const search = getSearch()
165
+ prefix = search + prefix
166
+ if (route.prefix[0] !== '?') {
167
+ const pathname = getPathname()
168
+ prefix = pathname + prefix
169
+ if (prefix[0] !== '/') prefix = '/' + prefix
170
+ }
171
+ }
172
+ const path = decodeURIComponentSafe(prefix).slice(route.prefix.length)
173
+ const data = parsePathname(path)
174
+
175
+ Object.assign(data.params, $window.history.state || {})
176
+
177
+ function reject(e: any) {
178
+ console.error(e)
179
+ route.set(fallbackRoute!, null, {replace: true})
180
+ }
181
+
182
+ loop(0)
183
+ function loop(i: number) {
184
+ if (!compiled) return
185
+ for (; i < compiled.length; i++) {
186
+ if (compiled[i].check(data)) {
187
+ let payload = compiled[i].component
188
+ const matchedRoute = compiled[i].route
189
+ const localComp = payload
190
+ // Store the RouteResolver if payload has both onmatch and render
191
+ // This allows us to preserve the resolver even after onmatch returns a component
192
+ const resolverWithRender =
193
+ payload &&
194
+ typeof payload === 'object' &&
195
+ payload.onmatch &&
196
+ payload.render &&
197
+ !payload.view &&
198
+ typeof payload !== 'function'
199
+ ? payload
200
+ : null
201
+
202
+ const update = (lastUpdate = function (comp: any) {
203
+ if (update !== lastUpdate) return
204
+ if (comp === SKIP) return loop(i + 1)
205
+ // Handle redirect objects: explicit redirect signal
206
+ if (isRedirect(comp)) {
207
+ // Extract redirect target path
208
+ const redirectPath = comp[REDIRECT]
209
+ // Trigger navigation to redirect target
210
+ route.set(redirectPath, null)
211
+ // Skip rendering current route - new route resolution will handle redirect target
212
+ return
213
+ }
214
+ // If we have a preserved resolver with render, use it
215
+ if (resolverWithRender) {
216
+ currentResolver = resolverWithRender
217
+ component =
218
+ comp != null && (typeof comp.view === 'function' || typeof comp === 'function') ? comp : 'div'
219
+ }
220
+ // If comp is a RouteResolver with render, set currentResolver instead of component
221
+ else if (comp && typeof comp === 'object' && comp.render && !comp.view && typeof comp !== 'function') {
222
+ currentResolver = comp
223
+ component = 'div' // Placeholder, won't be used since currentResolver.render will be called
224
+ } else {
225
+ currentResolver = null
226
+ component =
227
+ comp != null && (typeof comp.view === 'function' || typeof comp === 'function') ? comp : 'div'
228
+ }
229
+ attrs = data.params
230
+ currentPath = path
231
+ lastUpdate = null
232
+ if (hasBeenResolved) mountRedraw.redraw()
233
+ else {
234
+ hasBeenResolved = true
235
+ mountRedraw.mount(dom!, RouterRoot)
236
+ }
237
+ })
238
+ // There's no understating how much I *wish* I could
239
+ // use `async`/`await` here...
240
+ if (payload.view || typeof payload === 'function') {
241
+ payload = {}
242
+ update(localComp)
243
+ } else if (payload.onmatch) {
244
+ p.then(function () {
245
+ return payload.onmatch!(data.params, path, matchedRoute)
246
+ }).then(update, path === fallbackRoute ? null : reject)
247
+ } else if (payload.render) {
248
+ // RouteResolver with render method - update with resolver itself
249
+ update(payload)
250
+ } else update('div')
251
+ return
252
+ }
253
+ }
254
+
255
+ if (path === fallbackRoute) {
256
+ throw new Error('Could not resolve default route ' + fallbackRoute + '.')
257
+ }
258
+ route.set(fallbackRoute!, null, {replace: true})
259
+ }
260
+ }
261
+
262
+ function fireAsync() {
263
+ if (!scheduled) {
264
+ scheduled = true
265
+ // TODO: just do `mountRedraw.redraw()` here and elide the timer
266
+ // dependency. Note that this will muck with tests a *lot*, so it's
267
+ // not as easy of a change as it sounds.
268
+ setTimeout(resolveRoute)
269
+ }
270
+ }
271
+
272
+ function route(root: Element, defaultRoute: string, routes: Record<string, ComponentType | RouteResolver>) {
273
+ if (!root) throw new TypeError('DOM element being rendered to does not exist.')
274
+
275
+ compiled = Object.keys(routes).map(function (routePath) {
276
+ if (routePath[0] !== '/') throw new SyntaxError("Routes must start with a '/'.")
277
+ if (/:([^/.-]+)(\.{3})?:/.test(routePath)) {
278
+ throw new SyntaxError("Route parameter names must be separated with either '/', '.', or '-'.")
279
+ }
280
+ return {
281
+ route: routePath,
282
+ component: routes[routePath],
283
+ check: compileTemplate(routePath),
284
+ }
285
+ })
286
+ fallbackRoute = defaultRoute
287
+ if (defaultRoute != null) {
288
+ const defaultData = parsePathname(defaultRoute)
289
+
290
+ if (
291
+ !compiled.some(function (i) {
292
+ return i.check(defaultData)
293
+ })
294
+ ) {
295
+ throw new ReferenceError("Default route doesn't match any known routes.")
296
+ }
297
+ }
298
+ dom = root
299
+
300
+ $window.addEventListener('popstate', fireAsync, false)
301
+
302
+ ready = true
303
+
304
+ // The RouterRoot component is mounted when the route is first resolved.
305
+ resolveRoute()
306
+ }
307
+ route.set = function (path: string, data: Record<string, any> | null, options?: RouteOptions) {
308
+ if (lastUpdate != null) {
309
+ options = options || {}
310
+ options.replace = true
311
+ }
312
+ lastUpdate = null
313
+
314
+ path = buildPathname(path, data || {})
315
+ if (ready) {
316
+ // Router is initialized - use history API for navigation
317
+ const state = options ? options.state : null
318
+ const title = options ? options.title : null
319
+ if ($window?.history) {
320
+ if (options && options.replace) $window.history.replaceState(state, title, route.prefix + path)
321
+ else $window.history.pushState(state, title, route.prefix + path)
322
+ }
323
+ // Update URL before fireAsync so resolveRoute reads the new path
324
+ fireAsync()
325
+ // In SSR context (no $window), navigation is a no-op since we're just rendering HTML
326
+ } else {
327
+ // Router not yet initialized - use location.href for initial navigation
328
+ if ($window?.location) {
329
+ $window.location.href = route.prefix + path
330
+ }
331
+ // In SSR context (no $window), this is a no-op since we're just rendering HTML
332
+ }
333
+ }
334
+ route.get = function (): string {
335
+ // If currentPath is not set (e.g., during SSR before route.resolve is called),
336
+ // fall back to extracting pathname from __SSR_URL__ using the isomorphic URI API
337
+ if (currentPath === undefined) {
338
+ return getPathname()
339
+ }
340
+ return currentPath ?? ''
341
+ }
342
+ route.prefix = '#!'
343
+ route.link = function (vnode: VnodeType) {
344
+ return route.Link.view(vnode)
345
+ }
346
+ route.Link = {
347
+ view: function (vnode: VnodeType) {
348
+ // Omit the used parameters from the rendered element - they are
349
+ // internal. Also, censor the various lifecycle methods.
350
+ //
351
+ // We don't strip the other parameters because for convenience we
352
+ // let them be specified in the selector as well.
353
+ const child = hyperscript(
354
+ vnode.attrs?.selector || 'a',
355
+ censor(vnode.attrs || {}, ['options', 'params', 'selector', 'onclick']),
356
+ vnode.children,
357
+ )
358
+ let options: RouteOptions | undefined
359
+ let onclick: any
360
+ let href: string
361
+
362
+ // Let's provide a *right* way to disable a route link, rather than
363
+ // letting people screw up accessibility on accident.
364
+ //
365
+ // The attribute is coerced so users don't get surprised over
366
+ // `disabled: 0` resulting in a button that's somehow routable
367
+ // despite being visibly disabled.
368
+ if ((child.attrs!.disabled = Boolean(child.attrs!.disabled))) {
369
+ child.attrs!.href = null
370
+ child.attrs!['aria-disabled'] = 'true'
371
+ // If you *really* do want add `onclick` on a disabled link, use
372
+ // an `oncreate` hook to add it.
373
+ } else {
374
+ options = vnode.attrs?.options
375
+ onclick = vnode.attrs?.onclick
376
+ // Easier to build it now to keep it isomorphic.
377
+ href = buildPathname(child.attrs!.href || '', vnode.attrs?.params || {})
378
+ // Make Link isomorphic - use empty prefix on server for pathname routing
379
+ // On server ($window is null): always use empty prefix for clean URLs
380
+ // On client: use route.prefix (which may be '#!' for hash routing or '' for pathname routing)
381
+ // This ensures SSR generates clean pathname URLs while client can use hash routing if configured
382
+ const linkPrefix = $window == null ? '' : route.prefix
383
+ child.attrs!.href = linkPrefix + href
384
+ child.attrs!.onclick = function (e: any) {
385
+ let result: any
386
+ if (typeof onclick === 'function') {
387
+ result = onclick.call(e.currentTarget, e)
388
+ } else if (onclick == null || typeof onclick !== 'object') {
389
+ // do nothing
390
+ } else if (typeof onclick.handleEvent === 'function') {
391
+ onclick.handleEvent(e)
392
+ }
393
+
394
+ // Adapted from React Router's implementation:
395
+ // https://github.com/ReactTraining/react-router/blob/520a0acd48ae1b066eb0b07d6d4d1790a1d02482/packages/react-router-dom/modules/Link.js
396
+ //
397
+ // Try to be flexible and intuitive in how we handle links.
398
+ // Fun fact: links aren't as obvious to get right as you
399
+ // would expect. There's a lot more valid ways to click a
400
+ // link than this, and one might want to not simply click a
401
+ // link, but right click or command-click it to copy the
402
+ // link target, etc. Nope, this isn't just for blind people.
403
+ if (
404
+ // Skip if `onclick` prevented default
405
+ result !== false &&
406
+ !e.defaultPrevented &&
407
+ // Ignore everything but left clicks
408
+ (e.button === 0 || e.which === 0 || e.which === 1) &&
409
+ // Let the browser handle `target=_blank`, etc.
410
+ (!e.currentTarget.target || e.currentTarget.target === '_self') &&
411
+ // No modifier keys
412
+ !e.ctrlKey &&
413
+ !e.metaKey &&
414
+ !e.shiftKey &&
415
+ !e.altKey
416
+ ) {
417
+ // Safely call preventDefault - event might be wrapped by Mithril
418
+ if (typeof e.preventDefault === 'function') {
419
+ e.preventDefault()
420
+ } else if (e.originalEvent && typeof e.originalEvent.preventDefault === 'function') {
421
+ e.originalEvent.preventDefault()
422
+ }
423
+ ;(e as any).redraw = false
424
+ route.set(href, null, options)
425
+ }
426
+ }
427
+ }
428
+ return child
429
+ },
430
+ }
431
+ route.param = function (key?: string) {
432
+ return attrs && key != null ? attrs[key] : attrs
433
+ }
434
+ route.params = attrs
435
+
436
+ // Server-side route resolution (isomorphic)
437
+ route.resolve = async function (
438
+ pathname: string,
439
+ routes: Record<string, ComponentType | RouteResolver | {component: ComponentType | RouteResolver}>,
440
+ renderToString: (vnodes: any) => Promise<SSRResult>,
441
+ prefix: string = '',
442
+ redirectDepth: number = 0,
443
+ ): Promise<SSRResult> {
444
+ // Prevent infinite redirect loops
445
+ const MAX_REDIRECT_DEPTH = 5
446
+ if (redirectDepth > MAX_REDIRECT_DEPTH) {
447
+ throw new Error(`Maximum redirect depth (${MAX_REDIRECT_DEPTH}) exceeded. Possible redirect loop.`)
448
+ }
449
+ // Save current prefix and set to provided prefix for SSR
450
+ // This ensures Link components use the correct prefix during server-side rendering
451
+ const savedPrefix = route.prefix
452
+ route.prefix = prefix
453
+ // Save current path to restore after SSR
454
+ const savedCurrentPath = currentPath
455
+
456
+ // Set currentPath immediately so m.route.get() works during SSR
457
+ // Use pathname (full path) - this is what m.route.get() should return
458
+ currentPath = pathname || '/'
459
+
460
+ try {
461
+ // Compile routes (same logic as in route() function)
462
+ const compiled = Object.keys(routes).map(function (routePath) {
463
+ if (routePath[0] !== '/') throw new SyntaxError("Routes must start with a '/'.")
464
+ if (/:([^/.-]+)(\.{3})?:/.test(routePath)) {
465
+ throw new SyntaxError("Route parameter names must be separated with either '/', '.', or '-'.")
466
+ }
467
+ // Handle both formats: direct component/resolver or {component: ...}
468
+ const routeValue = routes[routePath]
469
+ const component =
470
+ routeValue && typeof routeValue === 'object' && 'component' in routeValue
471
+ ? (routeValue as {component: ComponentType | RouteResolver}).component
472
+ : (routeValue as ComponentType | RouteResolver)
473
+ return {
474
+ route: routePath,
475
+ component: component,
476
+ check: compileTemplate(routePath),
477
+ }
478
+ })
479
+
480
+ // Parse pathname
481
+ const path = decodeURIComponentSafe(pathname || '/').slice(prefix.length)
482
+ const data = parsePathname(path)
483
+
484
+ // Update attrs for SSR so m.route.param() works during server-side rendering
485
+ attrs = data.params
486
+
487
+ // Find matching route
488
+ for (const {route: matchedRoute, component, check} of compiled) {
489
+ if (check(data)) {
490
+ let payload = component
491
+
492
+ // Handle RouteResolver
493
+ if (payload && typeof payload === 'object' && ('onmatch' in payload || 'render' in payload)) {
494
+ const resolver = payload as RouteResolver
495
+ if (resolver.onmatch) {
496
+ const result = resolver.onmatch(data.params, pathname, matchedRoute)
497
+ if (result instanceof Promise) {
498
+ payload = await result
499
+ } else if (result !== undefined) {
500
+ payload = result as any
501
+ }
502
+ // Note: If onmatch returns undefined, payload remains as the RouteResolver
503
+ }
504
+
505
+ // Check for redirect BEFORE processing as component
506
+ // This prevents redirect objects from being treated as components
507
+ if (isRedirect(payload)) {
508
+ // Extract redirect target path (handles different REDIRECT symbols)
509
+ const redirectPath = getRedirectPath(payload)
510
+ logger.info(`redirecting to ${redirectPath}`, {
511
+ pathname,
512
+ route: matchedRoute,
513
+ redirectPath,
514
+ })
515
+ // Update __SSR_URL__ to reflect redirect target for proper URL context
516
+ // This ensures getCurrentUrl() and other URL-dependent code work correctly
517
+ const originalSSRUrl = globalThis.__SSR_URL__
518
+ try {
519
+ // Construct full URL for redirect target if we have original URL context
520
+ if (originalSSRUrl && typeof originalSSRUrl === 'string') {
521
+ try {
522
+ const originalUrl = new URL(originalSSRUrl)
523
+ // Build redirect target URL using same origin
524
+ const redirectUrl = new URL(redirectPath, originalUrl.origin)
525
+ globalThis.__SSR_URL__ = redirectUrl.href
526
+ } catch {
527
+ // If URL construction fails, just use redirect path as-is
528
+ globalThis.__SSR_URL__ = redirectPath
529
+ }
530
+ } else {
531
+ globalThis.__SSR_URL__ = redirectPath
532
+ }
533
+ // Recursively resolve redirect target route
534
+ // This will return SSRResult (string or {html, state})
535
+ const redirectResult = await route.resolve(
536
+ redirectPath,
537
+ routes,
538
+ renderToString,
539
+ prefix,
540
+ redirectDepth + 1,
541
+ )
542
+ const redirectHtml = typeof redirectResult === 'string' ? redirectResult : redirectResult.html
543
+ if (!redirectHtml || redirectHtml.length === 0) {
544
+ logger.warn('empty redirect result', {
545
+ pathname,
546
+ redirectPath,
547
+ route: matchedRoute,
548
+ })
549
+ } else {
550
+ logger.debug('redirect resolved', {
551
+ pathname,
552
+ redirectPath,
553
+ htmlSize: redirectHtml.length,
554
+ })
555
+ }
556
+ return redirectResult
557
+ } finally {
558
+ // Restore original SSR URL after redirect resolution
559
+ globalThis.__SSR_URL__ = originalSSRUrl
560
+ }
561
+ }
562
+
563
+ // If resolver has render, use it
564
+ if (resolver.render) {
565
+ // Only render if payload is a valid component (onmatch returned a component)
566
+ // If onmatch returned undefined, payload is still the RouteResolver, which is not a component
567
+ const isComponentType =
568
+ payload != null &&
569
+ payload !== resolver &&
570
+ (typeof payload === 'function' ||
571
+ (typeof payload === 'object' &&
572
+ 'view' in payload &&
573
+ typeof (payload as any).view === 'function'))
574
+
575
+ if (isComponentType) {
576
+ try {
577
+ // Create component vnode using hyperscript
578
+ const componentVnode = hyperscript(payload as ComponentType, data.params)
579
+
580
+ // Call resolver.render to get the layout-wrapped vnode
581
+ // resolver.render does: m(layout, null, componentVnode)
582
+ const renderedVnode = resolver.render(componentVnode)
583
+ const result = await renderToString(renderedVnode)
584
+ const html = typeof result === 'string' ? result : result.html
585
+ if (html) {
586
+ logger.info(`rendered route component`, {
587
+ pathname,
588
+ route: matchedRoute,
589
+ htmlSize: html.length,
590
+ })
591
+ }
592
+ return result
593
+ } catch (error) {
594
+ logger.error('route render failed', error, {
595
+ pathname,
596
+ route: matchedRoute,
597
+ })
598
+ throw error
599
+ }
600
+ }
601
+
602
+ // If resolver has render but no onmatch (or onmatch returned undefined),
603
+ // call render directly with a vnode that has routePath in attrs
604
+ // This allows render-only resolvers to work
605
+ if (!resolver.onmatch || payload === resolver) {
606
+ try {
607
+ // Create a vnode with the resolver as tag and routePath in attrs
608
+ // Use Vnode() directly since resolver is not a component
609
+ const resolverVnode = Vnode(
610
+ resolver as any,
611
+ pathname,
612
+ {
613
+ ...data.params,
614
+ routePath: pathname,
615
+ },
616
+ null,
617
+ null,
618
+ null,
619
+ )
620
+ const renderedVnode = resolver.render(resolverVnode)
621
+ const result = await renderToString(renderedVnode)
622
+ return result
623
+ } catch (error) {
624
+ logger.error('route render-only resolver failed', error, {
625
+ pathname,
626
+ route: matchedRoute,
627
+ })
628
+ throw error
629
+ }
630
+ }
631
+ // If payload is not a valid component, skip rendering
632
+ // This happens when onmatch returns undefined - render needs a component to work with
633
+ // In this case, we should fall through to the component rendering logic below
634
+ // which will handle the RouteResolver as a component if it has a view method
635
+ }
636
+ }
637
+
638
+ // Render component
639
+ // Check if payload is a ComponentType (not a RouteResolver)
640
+ const isComponentType =
641
+ payload != null &&
642
+ (typeof payload === 'function' ||
643
+ (typeof payload === 'object' && 'view' in payload && typeof (payload as any).view === 'function'))
644
+ if (isComponentType) {
645
+ const vnode = hyperscript(payload as ComponentType, data.params)
646
+ const result = await renderToString(vnode)
647
+ // Handle both string (backward compatibility) and {html, state} return types
648
+ return typeof result === 'string' ? result : result
649
+ }
650
+
651
+ // Fallback to div
652
+ const vnode = hyperscript('div', data.params)
653
+ return await renderToString(vnode)
654
+ }
655
+ }
656
+
657
+ // No route found
658
+ throw new Error(`No route found for ${pathname}`)
659
+ } finally {
660
+ // Restore original prefix and currentPath
661
+ route.prefix = savedPrefix
662
+ currentPath = savedCurrentPath
663
+ }
664
+ }
665
+
666
+ return route as unknown as Route &
667
+ ((root: Element, defaultRoute: string, routes: Record<string, ComponentType | RouteResolver>) => void) & {
668
+ redirect: (path: string) => RedirectObject
669
+ }
670
+ }
package/browser.ts ADDED
@@ -0,0 +1,10 @@
1
+ // @ts-nocheck
2
+ import m from './index'
3
+
4
+ if (typeof module !== 'undefined') {
5
+ ;(module as any)['exports'] = m
6
+ } else {
7
+ ;(window as any).m = m
8
+ }
9
+
10
+ export default m