fusee-framework 1.6.0 → 1.8.0

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 (129) hide show
  1. package/README.md +145 -123
  2. package/app/benchmark/benchmark.js +633 -0
  3. package/app/benchmark/index.html +102 -0
  4. package/app/benchmark/styles.css +249 -0
  5. package/app/components/Counter.js +17 -7
  6. package/app/components/CounterSection.js +3 -4
  7. package/app/main.js +50 -62
  8. package/app/main.ts +78 -63
  9. package/app/pages/404.js +16 -0
  10. package/app/pages/[...all].js +35 -0
  11. package/app/pages/[...all].ts +35 -0
  12. package/app/pages/_layout.js +53 -0
  13. package/app/pages/_layout.ts +26 -0
  14. package/app/pages/about.js +23 -0
  15. package/app/pages/about.ts +29 -0
  16. package/app/pages/async-work.js +65 -0
  17. package/app/pages/composables.js +158 -0
  18. package/app/pages/composables.ts +158 -0
  19. package/app/pages/dynamic.js +40 -0
  20. package/app/pages/dynamic.ts +40 -0
  21. package/app/pages/index.js +137 -0
  22. package/app/pages/index.ts +87 -0
  23. package/app/pages/login.ts +21 -0
  24. package/app/pages/objects/[id].js +56 -0
  25. package/app/pages/objects/[id].ts +56 -0
  26. package/app/pages/posts/[category]/[slug].js +56 -0
  27. package/app/pages/posts/[category]/[slug].ts +56 -0
  28. package/app/pages/router-example.js +79 -0
  29. package/app/pages/router-example.ts +56 -0
  30. package/app/pages/scroll-demo.ts +102 -0
  31. package/app/pages/test-for.js +65 -0
  32. package/app/pages/test-for.ts +80 -0
  33. package/app/pages/users/[id].js +24 -0
  34. package/app/pages/users/[id].ts +46 -0
  35. package/app/pages/users/index.js +22 -0
  36. package/app/pages/users/index.ts +29 -0
  37. package/app/pages/users.js +43 -0
  38. package/app/pages/users.ts +78 -0
  39. package/app/providers/ChildComponent.js +8 -2
  40. package/app/providers/ChildComponent.ts +47 -47
  41. package/app/providers/GrandchildComponent.js +19 -0
  42. package/app/providers/ParentComponent.js +5 -1
  43. package/app/routes/About.js +1 -1
  44. package/app/routes/About.ts +1 -1
  45. package/app/routes/AuthRoutes.ts +41 -0
  46. package/app/routes/Home.js +9 -0
  47. package/app/routes/NotFound.js +16 -0
  48. package/app/routes/NotFound.ts +16 -0
  49. package/app/routes/RouterExample.js +78 -0
  50. package/app/routes/RouterExample.ts +56 -0
  51. package/app/routes/TestResource.js +47 -0
  52. package/app/routes/WildcardDemo.js +35 -0
  53. package/app/routes/WildcardDemo.ts +35 -0
  54. package/app/services.js +15 -0
  55. package/auto-imports.d.ts +9 -0
  56. package/bin/create-fusee-linux +0 -0
  57. package/bin/create-fusee-mac-arm64 +0 -0
  58. package/bin/create-fusee-mac-intel +0 -0
  59. package/bin/create-fusee-win.exe +0 -0
  60. package/bin/create-fusee.exe +0 -0
  61. package/bin/index.js +38 -0
  62. package/create-fusee-go/cmd/generate.go +72 -0
  63. package/create-fusee-go/cmd/init.go +38 -0
  64. package/create-fusee-go/cmd/root.go +121 -0
  65. package/create-fusee-go/go.mod +10 -0
  66. package/create-fusee-go/go.sum +10 -0
  67. package/create-fusee-go/internal/assets/assets.go +78 -0
  68. package/create-fusee-go/internal/assets/embed/framework/auto-import-preset.js +9 -0
  69. package/create-fusee-go/internal/assets/embed/framework/core/compiler.js +253 -0
  70. package/create-fusee-go/internal/assets/embed/framework/core/component.js +266 -0
  71. package/create-fusee-go/internal/assets/embed/framework/core/composable.js +27 -0
  72. package/create-fusee-go/internal/assets/embed/framework/core/directives.js +875 -0
  73. package/create-fusee-go/internal/assets/embed/framework/core/evaluator.js +153 -0
  74. package/create-fusee-go/internal/assets/embed/framework/core/event-delegation.js +191 -0
  75. package/create-fusee-go/internal/assets/embed/framework/core/signal.js +412 -0
  76. package/create-fusee-go/internal/assets/embed/framework/core/store.js +81 -0
  77. package/create-fusee-go/internal/assets/embed/framework/index.d.ts +3 -0
  78. package/create-fusee-go/internal/assets/embed/framework/index.js +10 -0
  79. package/create-fusee-go/internal/assets/embed/framework/router/file-router.js +177 -0
  80. package/create-fusee-go/internal/assets/embed/framework/router/router.js +462 -0
  81. package/create-fusee-go/internal/assets/embed/framework/types/compiler.d.ts +15 -0
  82. package/create-fusee-go/internal/assets/embed/framework/types/component.d.ts +88 -0
  83. package/create-fusee-go/internal/assets/embed/framework/types/composable.d.ts +14 -0
  84. package/create-fusee-go/internal/assets/embed/framework/types/directives.d.ts +55 -0
  85. package/create-fusee-go/internal/assets/embed/framework/types/evaluator.d.ts +13 -0
  86. package/create-fusee-go/internal/assets/embed/framework/types/event-delegation.d.ts +32 -0
  87. package/create-fusee-go/internal/assets/embed/framework/types/index.d.ts +22 -0
  88. package/create-fusee-go/internal/assets/embed/framework/types/router.d.ts +32 -0
  89. package/create-fusee-go/internal/assets/embed/framework/types/signal.d.ts +79 -0
  90. package/create-fusee-go/internal/assets/embed/framework/types/store.d.ts +8 -0
  91. package/create-fusee-go/internal/assets/templates/Counter.tmpl +26 -0
  92. package/create-fusee-go/internal/assets/templates/component.tmpl +11 -0
  93. package/create-fusee-go/internal/assets/templates/components/Loading.tmpl +24 -0
  94. package/create-fusee-go/internal/assets/templates/index.html.tmpl +15 -0
  95. package/create-fusee-go/internal/assets/templates/main.tmpl +14 -0
  96. package/create-fusee-go/internal/assets/templates/package.json.tmpl +14 -0
  97. package/create-fusee-go/internal/assets/templates/page.tmpl +12 -0
  98. package/create-fusee-go/internal/assets/templates/pages/about.tmpl +20 -0
  99. package/create-fusee-go/internal/assets/templates/pages/index.tmpl +22 -0
  100. package/create-fusee-go/internal/assets/templates/pages/layout.tmpl +18 -0
  101. package/create-fusee-go/internal/assets/templates/tsconfig.json.tmpl +14 -0
  102. package/create-fusee-go/internal/assets/templates/vite.config.js.tmpl +18 -0
  103. package/create-fusee-go/main.go +7 -0
  104. package/framework/auto-import-preset.js +13 -9
  105. package/framework/core/compiler.js +252 -249
  106. package/framework/core/component.js +279 -260
  107. package/framework/core/di.js +187 -0
  108. package/framework/core/directives.js +819 -786
  109. package/framework/core/evaluator.js +153 -70
  110. package/framework/core/event-delegation.js +33 -14
  111. package/framework/core/reconcile.js +151 -0
  112. package/framework/core/signal.js +679 -403
  113. package/framework/core/store.js +322 -81
  114. package/framework/index.js +12 -11
  115. package/framework/router/file-router.js +177 -0
  116. package/framework/router/router.js +554 -80
  117. package/framework/types/component.d.ts +89 -88
  118. package/framework/types/di.d.ts +43 -0
  119. package/framework/types/event-delegation.d.ts +0 -58
  120. package/framework/types/index.d.ts +32 -22
  121. package/framework/types/reconcile.d.ts +7 -0
  122. package/framework/types/router.d.ts +42 -15
  123. package/framework/types/signal.d.ts +147 -78
  124. package/framework/types/store.d.ts +66 -8
  125. package/index.html +638 -626
  126. package/package.json +5 -3
  127. package/tsconfig.json +1 -0
  128. package/vite.config.js +7 -1
  129. /package/{framework → create-fusee-go/internal/assets/embed/framework}/bin/create-fusee.js +0 -0
@@ -1,80 +1,554 @@
1
- let _routes = []
2
- let _outlet = null
3
- let _currentInstance = null
4
-
5
- export function createRouter(routes) {
6
- _routes = routes
7
-
8
- const handler = () => _resolve()
9
- window.addEventListener('hashchange', handler)
10
-
11
- if (document.readyState === 'loading') {
12
- window.addEventListener('DOMContentLoaded', handler, { once: true })
13
- } else {
14
- setTimeout(() => {
15
- if (!_currentInstance) _resolve()
16
- }, 0)
17
- }
18
-
19
- return {
20
- navigate,
21
- destroy() {
22
- window.removeEventListener('hashchange', handler)
23
- window.removeEventListener('DOMContentLoaded', handler)
24
- if (_currentInstance) {
25
- try {
26
- _currentInstance.unmount()
27
- } catch { }
28
- }
29
- _routes = []
30
- _outlet = null
31
- _currentInstance = null
32
- }
33
- }
34
- }
35
-
36
- export function navigate(path) {
37
- window.location.hash = path
38
- }
39
-
40
- export function mountOutlet(el) {
41
- _outlet = el
42
- _resolve()
43
- }
44
-
45
- function _resolve() {
46
- if (!_outlet || _routes.length === 0) {
47
- return
48
- }
49
-
50
- const hash = window.location.hash
51
- const path = hash.startsWith('#') ? hash.slice(1) || '/' : '/'
52
-
53
- const matched = _routes.find(r => _matchPath(r.path, path))
54
-
55
- if (!matched) {
56
- _outlet.innerHTML = `<p style="color:red">[framework] No route matched for "${path}"</p>`
57
- return
58
- }
59
-
60
- if (_currentInstance) {
61
- try { _currentInstance.unmount() } catch { }
62
- }
63
-
64
- _outlet.innerHTML = ''
65
-
66
- const componentFn = matched.component
67
- _currentInstance = componentFn()
68
- _currentInstance.render(_outlet)
69
- }
70
-
71
- function _matchPath(routePath, actualPath) {
72
- const routeParts = routePath.split('/').filter(Boolean)
73
- const actualParts = actualPath.split('/').filter(Boolean)
74
-
75
- if (routeParts.length !== actualParts.length) return false
76
-
77
- return routeParts.every((part, i) =>
78
- part.startsWith(':') || part === actualParts[i]
79
- )
80
- }
1
+ import { signal } from '../core/signal.js'
2
+
3
+ export const currentRoute = signal('/')
4
+ export const routeParams = signal({})
5
+ export const routeQuery = signal({})
6
+ export const matchedRoutes = signal([])
7
+
8
+ let _routes = []
9
+ let _rootOutlet = null
10
+ let _clickHandler = null
11
+ let _activeChain = []
12
+ let _routeCache = new Map()
13
+ let _cacheMaxSize = 100
14
+ let _routerViewTimeout = 10000
15
+ let _scrollBehaviorOptions = null
16
+ let _scrollPositions = new Map()
17
+ let _currentPath = null
18
+
19
+ function _getPath() {
20
+ const pathname = window.location.pathname || '/'
21
+ const search = window.location.search || ''
22
+ const hash = window.location.hash || ''
23
+ return pathname + search + hash
24
+ }
25
+
26
+ function _extractQuery(path) {
27
+ const queryStr = path.split('?')[1]?.split('#')[0]
28
+ if (!queryStr) return {}
29
+ const params = new URLSearchParams(queryStr)
30
+ const query = {}
31
+ for (const [key, value] of params.entries()) {
32
+ query[key] = value
33
+ }
34
+ return query
35
+ }
36
+
37
+ function _getRoutePaths(routePath) {
38
+ return Array.isArray(routePath) ? routePath : [routePath]
39
+ }
40
+
41
+ function _updateRoute() {
42
+ currentRoute(_getPath())
43
+ }
44
+
45
+ function _matchSegments(routePath, urlSegments) {
46
+ const cleanPath = routePath.startsWith('/') ? routePath.slice(1) : routePath
47
+
48
+ if (cleanPath === '') {
49
+ return { matched: true, consumed: 0, params: {} }
50
+ }
51
+
52
+ if (cleanPath === '*') {
53
+ return { matched: true, consumed: urlSegments.length, params: {} }
54
+ }
55
+
56
+ const routeSegs = cleanPath.split('/').filter(Boolean)
57
+ const hasWildcard = routeSegs[routeSegs.length - 1] === '*'
58
+ const checkSegs = hasWildcard ? routeSegs.slice(0, -1) : routeSegs
59
+
60
+ if (hasWildcard) {
61
+ if (urlSegments.length < checkSegs.length) return null
62
+ }
63
+
64
+ if (urlSegments.length < checkSegs.length) return null
65
+
66
+ const params = {}
67
+ for (let i = 0; i < checkSegs.length; i++) {
68
+ const rSeg = checkSegs[i]
69
+ const uSeg = urlSegments[i]
70
+ if (rSeg.startsWith(':')) {
71
+ params[rSeg.slice(1)] = uSeg
72
+ } else if (rSeg !== uSeg) {
73
+ return null
74
+ }
75
+ }
76
+
77
+ const consumed = hasWildcard ? urlSegments.length : checkSegs.length
78
+ return { matched: true, consumed, params }
79
+ }
80
+
81
+ function _matchRouteTree(routes, urlSegments) {
82
+ for (const route of routes) {
83
+ const paths = _getRoutePaths(route.path)
84
+ for (const path of paths) {
85
+ const result = _matchSegments(path, urlSegments)
86
+ if (!result) continue
87
+
88
+ const remaining = urlSegments.slice(result.consumed)
89
+
90
+ if (route.children && route.children.length > 0) {
91
+ const childChain = _matchRouteTree(route.children, remaining)
92
+ if (childChain) {
93
+ return [
94
+ { route, params: result.params, matchedSegments: urlSegments.slice(0, result.consumed), matchedPath: path },
95
+ ...childChain
96
+ ]
97
+ }
98
+ continue
99
+ }
100
+
101
+ if (remaining.length === 0 || path === '*' || path.endsWith('/*')) {
102
+ return [{ route, params: result.params, matchedSegments: urlSegments.slice(0, result.consumed), matchedPath: path }]
103
+ }
104
+ }
105
+ }
106
+
107
+ return null
108
+ }
109
+
110
+ function _matchSingleFlat(routePath, actualPath) {
111
+ const actualPathWithoutParams = actualPath.split(/[?#]/)[0]
112
+ const routeParts = routePath.split('/').filter(Boolean)
113
+ const actualParts = actualPathWithoutParams.split('/').filter(Boolean)
114
+
115
+ const hasWildcard = routeParts.length > 0 && routeParts[routeParts.length - 1] === '*'
116
+
117
+ if (hasWildcard) {
118
+ const baseParts = routeParts.slice(0, -1)
119
+ if (actualParts.length < baseParts.length) return false
120
+ return baseParts.every((part, i) => part.startsWith(':') || part === actualParts[i])
121
+ }
122
+
123
+ if (routeParts.length !== actualParts.length) return false
124
+ return routeParts.every((part, i) => part.startsWith(':') || part === actualParts[i])
125
+ }
126
+
127
+ function _matchFlat(routePath, actualPath) {
128
+ const paths = _getRoutePaths(routePath)
129
+ return paths.some(p => _matchSingleFlat(p, actualPath))
130
+ }
131
+
132
+ function _findMatchingPath(routePath, actualPath) {
133
+ const paths = _getRoutePaths(routePath)
134
+ return paths.find(p => _matchSingleFlat(p, actualPath)) || null
135
+ }
136
+
137
+ function _extractParamsFromPath(singlePath, actualPath) {
138
+ const params = {}
139
+ if (singlePath === '*') return params
140
+
141
+ const actualPathWithoutParams = actualPath.split(/[?#]/)[0]
142
+ const routeParts = singlePath.split('/').filter(Boolean)
143
+ const actualParts = actualPathWithoutParams.split('/').filter(Boolean)
144
+
145
+ const hasWildcard = routeParts.length > 0 && routeParts[routeParts.length - 1] === '*'
146
+ const checkLen = hasWildcard ? routeParts.length - 1 : routeParts.length
147
+
148
+ for (let i = 0; i < checkLen; i++) {
149
+ if (routeParts[i] && routeParts[i].startsWith(':')) {
150
+ params[routeParts[i].slice(1)] = actualParts[i] || ''
151
+ }
152
+ }
153
+ return params
154
+ }
155
+
156
+ function _extractParamsFlat(routePath, actualPath) {
157
+ const matchingPath = _findMatchingPath(routePath, actualPath)
158
+ if (!matchingPath) return {}
159
+ return _extractParamsFromPath(matchingPath, actualPath)
160
+ }
161
+
162
+ function _findMatchingChain(path) {
163
+ const pathWithoutParams = path.split(/[?#]/)[0]
164
+
165
+ if (_routeCache.has(pathWithoutParams)) {
166
+ const cached = _routeCache.get(pathWithoutParams)
167
+ _routeCache.delete(pathWithoutParams)
168
+ _routeCache.set(pathWithoutParams, cached)
169
+ return cached
170
+ }
171
+
172
+ const urlSegments = pathWithoutParams.split('/').filter(Boolean)
173
+ const hasNested = _routes.some(r => r.children && r.children.length > 0)
174
+
175
+ let chain = null
176
+
177
+ if (hasNested) {
178
+ chain = _matchRouteTree(_routes, urlSegments)
179
+ if (chain) {
180
+ _cacheResult(pathWithoutParams, chain)
181
+ return chain
182
+ }
183
+ }
184
+
185
+ const paths = _routes.filter(r => {
186
+ const routePaths = _getRoutePaths(r.path)
187
+ return !routePaths.includes('*') && !routePaths.some(p => p.endsWith('/*')) && !r.children
188
+ })
189
+ const exactMatch = paths.find(r => _matchFlat(r.path, pathWithoutParams))
190
+ if (exactMatch) {
191
+ const matchedPath = _findMatchingPath(exactMatch.path, pathWithoutParams)
192
+ chain = [{ route: exactMatch, params: _extractParamsFlat(exactMatch.path, pathWithoutParams), matchedSegments: urlSegments, matchedPath }]
193
+ _cacheResult(pathWithoutParams, chain)
194
+ return chain
195
+ }
196
+
197
+ const wildcardPaths = _routes.filter(r => {
198
+ const routePaths = _getRoutePaths(r.path)
199
+ return !r.children && routePaths.some(p => p.endsWith('/*'))
200
+ })
201
+ const wildcardMatch = wildcardPaths.find(r => _matchFlat(r.path, pathWithoutParams))
202
+ if (wildcardMatch) {
203
+ const matchedPath = _findMatchingPath(wildcardMatch.path, pathWithoutParams)
204
+ chain = [{ route: wildcardMatch, params: _extractParamsFlat(wildcardMatch.path, pathWithoutParams), matchedSegments: urlSegments, matchedPath }]
205
+ _cacheResult(pathWithoutParams, chain)
206
+ return chain
207
+ }
208
+
209
+ const catchAll = _routes.find(r => {
210
+ const routePaths = _getRoutePaths(r.path)
211
+ return routePaths.includes('*')
212
+ })
213
+ if (catchAll) {
214
+ chain = [{ route: catchAll, params: {}, matchedSegments: urlSegments, matchedPath: '*' }]
215
+ _cacheResult(pathWithoutParams, chain)
216
+ return chain
217
+ }
218
+
219
+ return null
220
+ }
221
+
222
+ function _cacheResult(path, chain) {
223
+ if (_routeCache.size >= _cacheMaxSize) {
224
+ const firstKey = _routeCache.keys().next().value
225
+ _routeCache.delete(firstKey)
226
+ }
227
+ _routeCache.set(path, chain)
228
+ }
229
+
230
+ function _clearRouteCache() {
231
+ _routeCache.clear()
232
+ }
233
+
234
+ function _saveScrollPosition(path) {
235
+ if (!_scrollBehaviorOptions?.saveScrollPosition) return
236
+ _scrollPositions.set(path, {
237
+ left: window.scrollX,
238
+ top: window.scrollY
239
+ })
240
+ }
241
+
242
+ function _getSavedScrollPosition(path) {
243
+ return _scrollPositions.get(path) || null
244
+ }
245
+
246
+ function _applyScrollBehavior(to, from) {
247
+ const options = _scrollBehaviorOptions
248
+ if (!options) return
249
+
250
+ const savedPosition = _getSavedScrollPosition(to)
251
+
252
+ if (options.custom) {
253
+ const result = options.custom(to, from, savedPosition)
254
+ if (result && typeof result.then === 'function') {
255
+ result.then(pos => _performScroll(pos))
256
+ } else {
257
+ _performScroll(result)
258
+ }
259
+ return
260
+ }
261
+
262
+ if (options.scrollToAnchor && to.includes('#')) {
263
+ const anchor = to.split('#')[1]
264
+ const element = document.getElementById(anchor)
265
+ if (element) {
266
+ element.scrollIntoView({ behavior: 'smooth' })
267
+ return
268
+ }
269
+ }
270
+
271
+ if (options.scrollToTop !== false) {
272
+ window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
273
+ } else if (savedPosition && options.saveScrollPosition) {
274
+ window.scrollTo(savedPosition.left, savedPosition.top)
275
+ }
276
+ }
277
+
278
+ function _performScroll(position) {
279
+ if (!position || position === false) return
280
+
281
+ if (typeof position === 'object' && 'selector' in position) {
282
+ const element = document.querySelector(position.selector)
283
+ if (element) {
284
+ element.scrollIntoView({ behavior: 'smooth' })
285
+ }
286
+ } else if (typeof position === 'object' && 'left' in position) {
287
+ window.scrollTo({ left: position.left, top: position.top, behavior: 'smooth' })
288
+ }
289
+ }
290
+
291
+ function _unmountFromLevel(level) {
292
+ for (let i = _activeChain.length - 1; i >= level; i--) {
293
+ const entry = _activeChain[i]
294
+ if (entry && entry.instance) {
295
+ try { entry.instance.unmount() } catch { }
296
+ }
297
+ }
298
+ _activeChain.length = level
299
+ }
300
+
301
+ function _getRoutePathCount(route) {
302
+ return Array.isArray(route.path) ? route.path.length : 1
303
+ }
304
+
305
+ function _renderChain(chain) {
306
+ try {
307
+ let reuseUntil = 0
308
+ for (let i = 0; i < Math.min(_activeChain.length, chain.length); i++) {
309
+ const active = _activeChain[i]
310
+ const incoming = chain[i]
311
+
312
+ if (
313
+ active.route === incoming.route &&
314
+ _segmentsEqual(active.matchedSegments, incoming.matchedSegments)
315
+ ) {
316
+ reuseUntil = i + 1
317
+ }
318
+ else if (
319
+ active.route === incoming.route &&
320
+ _getRoutePathCount(active.route) > 1
321
+ ) {
322
+ reuseUntil = i + 1
323
+ }
324
+ else {
325
+ break
326
+ }
327
+ }
328
+
329
+ _unmountFromLevel(reuseUntil)
330
+
331
+ const allParams = {}
332
+ for (const entry of chain) {
333
+ Object.assign(allParams, entry.params)
334
+ }
335
+ routeParams(allParams)
336
+ matchedRoutes(chain.map(e => e.route))
337
+ _renderFromLevel(chain, reuseUntil)
338
+ } catch (error) {
339
+ console.error('[framework] Error rendering route chain:', error)
340
+ _unmountFromLevel(0)
341
+ if (_rootOutlet) {
342
+ _rootOutlet.innerHTML = `<p style="color:red">[framework] Error rendering route: ${error.message}</p>`
343
+ }
344
+ }
345
+ }
346
+
347
+ function _renderFromLevel(chain, level) {
348
+ if (level >= chain.length) return
349
+
350
+ const entry = chain[level]
351
+ const outlet = _getOutletForLevel(level)
352
+
353
+ if (!outlet) {
354
+ _waitForRouterView(level, () => _renderFromLevel(chain, level))
355
+ return
356
+ }
357
+
358
+ outlet.innerHTML = ''
359
+
360
+ const componentFn = entry.route.component
361
+ const instance = componentFn()
362
+ instance.render(outlet)
363
+
364
+ _activeChain[level] = {
365
+ route: entry.route,
366
+ matchedSegments: entry.matchedSegments,
367
+ params: entry.params,
368
+ instance,
369
+ outlet
370
+ }
371
+
372
+ if (level + 1 < chain.length) _scheduleNextLevel(chain, level + 1)
373
+ }
374
+
375
+ function _scheduleNextLevel(chain, level) {
376
+ const outlet = _getOutletForLevel(level)
377
+ if (outlet) {
378
+ _renderFromLevel(chain, level)
379
+ return
380
+ }
381
+
382
+ _waitForRouterView(level, () => _renderFromLevel(chain, level))
383
+ }
384
+
385
+ function _getOutletForLevel(level) {
386
+ if (level === 0) return _rootOutlet
387
+
388
+ const parentEntry = _activeChain[level - 1]
389
+ if (!parentEntry || !parentEntry.outlet) return null
390
+
391
+ return parentEntry.outlet.querySelector('[data-router-view]')
392
+ }
393
+
394
+ function _waitForRouterView(level, callback) {
395
+ const parentEntry = _activeChain[level - 1]
396
+ if (!parentEntry || !parentEntry.outlet) return
397
+
398
+ const parentOutlet = parentEntry.outlet
399
+
400
+ const existing = parentOutlet.querySelector('[data-router-view]')
401
+ if (existing) {
402
+ callback()
403
+ return
404
+ }
405
+
406
+ const observer = new MutationObserver(() => {
407
+ const el = parentOutlet.querySelector('[data-router-view]')
408
+ if (el) {
409
+ observer.disconnect()
410
+ callback()
411
+ }
412
+ })
413
+
414
+ observer.observe(parentOutlet, { childList: true, subtree: true })
415
+
416
+ setTimeout(() => observer.disconnect(), _routerViewTimeout)
417
+ }
418
+
419
+ function _segmentsEqual(a, b) {
420
+ if (a.length !== b.length) return false
421
+ for (let i = 0; i < a.length; i++) {
422
+ if (a[i] !== b[i]) return false
423
+ }
424
+ return true
425
+ }
426
+
427
+ function _resolveRoute() {
428
+ if (!_rootOutlet || _routes.length === 0) return
429
+
430
+ const path = _getPath()
431
+ const chain = _findMatchingChain(path)
432
+
433
+ if (!chain) {
434
+ _unmountFromLevel(0)
435
+ _rootOutlet.innerHTML = `<p style="color:red">[framework] No route matched for "${path}"</p>`
436
+ routeParams({})
437
+ matchedRoutes([])
438
+ return
439
+ }
440
+
441
+ _renderChain(chain)
442
+
443
+ const from = _currentPath
444
+ _currentPath = path
445
+ _applyScrollBehavior(path, from)
446
+ routeQuery(_extractQuery(path))
447
+ }
448
+
449
+ export function navigate(path) {
450
+ const currentPath = _getPath().split(/[?#]/)[0]
451
+ const targetPath = path.split(/[?#]/)[0]
452
+ if (targetPath === currentPath && path === _getPath()) {
453
+ _applyScrollBehavior(path, _getPath())
454
+ return
455
+ }
456
+ _saveScrollPosition(_getPath())
457
+ window.history.pushState({}, '', path)
458
+ _updateRoute()
459
+ _resolveRoute()
460
+ }
461
+
462
+ export function mountOutlet(el) {
463
+ _rootOutlet = el
464
+ _resolveRoute()
465
+ }
466
+
467
+ function _isInternalLink(anchor) {
468
+ const href = anchor.getAttribute('href')
469
+ if (!href) return false
470
+ if (href.startsWith('#')) return false
471
+ if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//')) {
472
+ const url = new URL(href, window.location.origin)
473
+ return url.origin === window.location.origin
474
+ }
475
+ return href.startsWith('/')
476
+ }
477
+
478
+ function _getAnchorFromEvent(e) {
479
+ if (e.composedPath) {
480
+ return e.composedPath().find(el => el.tagName === 'A')
481
+ }
482
+ return e.target.tagName === 'A' ? e.target : null
483
+ }
484
+
485
+ function _handleLinkClick(e) {
486
+ const anchor = _getAnchorFromEvent(e)
487
+ if (!anchor) return
488
+
489
+ if (anchor.hasAttribute('f-link') || _isInternalLink(anchor)) {
490
+ e.preventDefault()
491
+ const href = anchor.getAttribute('href')
492
+ if (href && href !== _getPath()) {
493
+ navigate(href)
494
+ }
495
+ }
496
+ }
497
+
498
+ function _setupPopstateHandler() {
499
+ const handler = () => {
500
+ _updateRoute()
501
+ _resolveRoute()
502
+ }
503
+ window.addEventListener('popstate', handler)
504
+ return handler
505
+ }
506
+
507
+ function _setupClickHandler() {
508
+ _clickHandler = _handleLinkClick
509
+ document.addEventListener('click', _clickHandler)
510
+ return _clickHandler
511
+ }
512
+
513
+ function _setupInitialRoute() {
514
+ if (document.readyState === 'loading') {
515
+ window.addEventListener('DOMContentLoaded', () => {
516
+ _updateRoute()
517
+ _resolveRoute()
518
+ }, { once: true })
519
+ } else {
520
+ setTimeout(() => {
521
+ if (_activeChain.length === 0) _resolveRoute()
522
+ }, 0)
523
+ }
524
+ }
525
+
526
+ export function createRouter(routes, options = {}) {
527
+ _routes = routes
528
+ _activeChain = []
529
+ _cacheMaxSize = options.cacheSize || 100
530
+ _routerViewTimeout = options.routerViewTimeout || 10000
531
+ _scrollBehaviorOptions = options.scrollBehavior || null
532
+
533
+ _updateRoute()
534
+
535
+ const popstateHandler = _setupPopstateHandler()
536
+ _setupClickHandler()
537
+ _setupInitialRoute()
538
+
539
+ return {
540
+ navigate,
541
+ destroy() {
542
+ window.removeEventListener('popstate', popstateHandler)
543
+ document.removeEventListener('click', _clickHandler)
544
+ _unmountFromLevel(0)
545
+ _routes = []
546
+ _rootOutlet = null
547
+ _activeChain = []
548
+ _clearRouteCache()
549
+ _scrollPositions.clear()
550
+ _scrollBehaviorOptions = null
551
+ _currentPath = null
552
+ }
553
+ }
554
+ }