@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.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/api/mount-redraw.ts +173 -0
- package/api/router-server.ts +88 -0
- package/api/router.ts +670 -0
- package/browser.ts +10 -0
- package/htm.ts +7 -0
- package/hyperscript.ts +8 -0
- package/index.ts +134 -0
- package/jsx.d.ts +16 -0
- package/mount.ts +10 -0
- package/package.json +66 -0
- package/pathname/build.ts +40 -0
- package/pathname/compileTemplate.ts +49 -0
- package/pathname/parse.ts +19 -0
- package/querystring/build.ts +22 -0
- package/querystring/parse.ts +50 -0
- package/redraw.ts +10 -0
- package/render/cachedAttrsIsStaticMap.ts +10 -0
- package/render/delayedRemoval.ts +1 -0
- package/render/domFor.ts +23 -0
- package/render/emptyAttrs.ts +2 -0
- package/render/fragment.ts +11 -0
- package/render/hyperscript.ts +125 -0
- package/render/hyperscriptVnode.ts +21 -0
- package/render/render.ts +1307 -0
- package/render/renderToString.ts +494 -0
- package/render/ssrState.ts +366 -0
- package/render/trust.ts +6 -0
- package/render/vnode.ts +131 -0
- package/route.ts +11 -0
- package/server/logger.ts +223 -0
- package/server/session.ts +87 -0
- package/server/ssr.ts +205 -0
- package/server/ssrLogger.ts +13 -0
- package/server.ts +101 -0
- package/signal.ts +320 -0
- package/ssrContext.ts +87 -0
- package/state.ts +1026 -0
- package/store.ts +457 -0
- package/util/censor.ts +46 -0
- package/util/decodeURIComponentSafe.ts +34 -0
- package/util/hasOwn.ts +2 -0
- package/util/next_tick.ts +45 -0
- package/util/ssr.ts +385 -0
- package/util/uri.ts +126 -0
- 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
|
+
}
|