@lightningjs/blits 2.3.0 → 2.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.4.0
4
+
5
+ _1 june 2026_
6
+
7
+ - Improved correct registration of reactivity in computed props
8
+ - Added named router views to enable multiple nested router views
9
+
3
10
  ## v2.3.0
4
11
 
5
12
  _20 may 2026_
package/index.d.ts CHANGED
@@ -329,7 +329,14 @@ declare module '@lightningjs/blits' {
329
329
  *
330
330
  * @param {string}
331
331
  */
332
- to(location: string, data?: RouteData, options?: RouteOptions): void;
332
+ to(location: string, data?: RouteData, options?: RouteOptions): boolean;
333
+
334
+ /**
335
+ * Get a router scoped to a named RouterView
336
+ *
337
+ * @param {string}
338
+ */
339
+ get(name: string): Router;
333
340
 
334
341
  /**
335
342
  * Navigate to the previous location
@@ -707,6 +714,10 @@ declare module '@lightningjs/blits' {
707
714
  * Watchers for changes to state variables, props or computed properties
708
715
  */
709
716
  watch?: W & ComponentContext<P, S, M, C>
717
+ /**
718
+ * Router Configuration
719
+ */
720
+ router?: RouterConfig<P, S, M, C>
710
721
  }
711
722
 
712
723
  export interface RouterHooks {
@@ -759,7 +770,7 @@ declare module '@lightningjs/blits' {
759
770
 
760
771
  export type ApplicationConfig<P extends Props, S, M, C, W> = Omit<
761
772
  ComponentConfig<P, S, M, C, W>,
762
- 'hooks' | 'methods' | 'input' | 'computed' | 'watch' | 'state'
773
+ 'hooks' | 'methods' | 'input' | 'computed' | 'watch' | 'state' | 'router' | 'routes'
763
774
  > & {
764
775
  hooks?: Hooks & ApplicationContext<P, S, M, C>
765
776
  methods?: M & ApplicationContext<P, S, M, C>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -16,31 +16,145 @@
16
16
  */
17
17
 
18
18
  import symbols from '../../lib/symbols.js'
19
- import { to, currentRoute, back, state } from '../../router/router.js'
19
+ import {
20
+ back,
21
+ currentRoute,
22
+ getRegisteredRouterView,
23
+ getSingleRegisteredRouterView,
24
+ state,
25
+ toRouterView,
26
+ } from '../../router/router.js'
27
+
28
+ const routerCache = new WeakMap()
29
+
30
+ const isRouterView = (component, routerViewName = '') => {
31
+ if (component === undefined || component === null) return false
32
+ return (
33
+ Array.isArray(component.history) && (routerViewName === '' || component.name === routerViewName)
34
+ )
35
+ }
36
+
37
+ const getRouterViewInChildren = (component, routerViewName = '') => {
38
+ if (component === undefined || component === null) return null
39
+
40
+ const children = component[symbols.children]
41
+ if (Array.isArray(children) === false) return null
42
+
43
+ for (let i = 0; i < children.length; i++) {
44
+ const child = children[i]
45
+ if (isRouterView(child, routerViewName)) return child
46
+
47
+ const nestedRouterView = getRouterViewInChildren(child, routerViewName)
48
+ if (nestedRouterView !== null) return nestedRouterView
49
+ }
50
+
51
+ return null
52
+ }
53
+
54
+ const getRouterView = (component, routerViewName = '') => {
55
+ let current = component
56
+ while (current !== undefined && current !== null) {
57
+ if (isRouterView(current, routerViewName)) {
58
+ return current
59
+ }
60
+ current = current[symbols.parent]
61
+ }
62
+ return getRouterViewInChildren(component, routerViewName)
63
+ }
64
+
65
+ const resolveRouterView = (component, routerViewName) => {
66
+ if (routerViewName !== undefined) {
67
+ const registeredRouterView = getRegisteredRouterView(routerViewName)
68
+ if (registeredRouterView !== null) return registeredRouterView
69
+
70
+ return getRouterView(component, routerViewName)
71
+ }
72
+
73
+ const singleRouterView = getSingleRegisteredRouterView()
74
+ if (singleRouterView !== null) return singleRouterView
75
+
76
+ return getRouterView(component)
77
+ }
78
+
79
+ const getRoutes = (component, routerViewName) => {
80
+ const routerView = resolveRouterView(component, routerViewName)
81
+ if (
82
+ routerView !== null &&
83
+ routerView[symbols.parent] !== undefined &&
84
+ routerView[symbols.parent][symbols.routes] !== undefined
85
+ ) {
86
+ return routerView[symbols.parent][symbols.routes]
87
+ }
88
+
89
+ if (component[symbols.routes] !== undefined) return component[symbols.routes]
90
+
91
+ if (
92
+ component[symbols.parent] !== undefined &&
93
+ component[symbols.parent][symbols.routes] !== undefined
94
+ ) {
95
+ return component[symbols.parent][symbols.routes]
96
+ }
97
+
98
+ return undefined
99
+ }
100
+
101
+ const getRouterFromCache = (component, routerViewName) => {
102
+ let routers = routerCache.get(component)
103
+ if (routers === undefined) {
104
+ routers = new Map()
105
+ routerCache.set(component, routers)
106
+ }
107
+
108
+ const cacheKey = routerViewName === undefined ? '' : routerViewName
109
+ const router = routers.get(cacheKey)
110
+ if (router !== undefined) return router
111
+
112
+ const newRouter = createRouter(component, routerViewName)
113
+ routers.set(cacheKey, newRouter)
114
+ return newRouter
115
+ }
116
+
117
+ const createRouter = (component, routerViewName) => {
118
+ return {
119
+ to(path, data = {}, options = {}) {
120
+ const routerView = resolveRouterView(component, routerViewName)
121
+ if (routerView === null) return false
122
+
123
+ toRouterView(routerView, path, data, options)
124
+ return true
125
+ },
126
+ back() {
127
+ const routerView = resolveRouterView(component, routerViewName)
128
+ return routerView === null ? false : back.call(routerView)
129
+ },
130
+ get(name) {
131
+ return getRouterFromCache(component, name)
132
+ },
133
+ get backNavigation() {
134
+ return state.backNavigation !== false
135
+ },
136
+ set backNavigation(enabled) {
137
+ state.backNavigation = enabled !== false
138
+ },
139
+ get currentRoute() {
140
+ const routerView = resolveRouterView(component, routerViewName)
141
+ return routerView === null ? currentRoute : routerView.currentRoute
142
+ },
143
+ get routes() {
144
+ return getRoutes(component, routerViewName)
145
+ },
146
+ get navigating() {
147
+ return state.navigating
148
+ },
149
+ state,
150
+ }
151
+ }
20
152
 
21
153
  export default {
22
154
  $router: {
23
- value: {
24
- to,
25
- back,
26
- get backNavigation() {
27
- return state.backNavigation !== false
28
- },
29
- set backNavigation(enabled) {
30
- state.backNavigation = enabled !== false
31
- },
32
- get currentRoute() {
33
- return currentRoute
34
- },
35
- get routes() {
36
- return this[symbols.routes]
37
- },
38
- get navigating() {
39
- return state.navigating
40
- },
41
- state,
42
- },
43
- writable: false,
155
+ get() {
156
+ return getRouterFromCache(this)
157
+ },
44
158
  enumerable: true,
45
159
  configurable: false,
46
160
  },
@@ -16,12 +16,14 @@
16
16
  */
17
17
 
18
18
  import Component from '../component.js'
19
- import Router, { state as routerState } from '../router/router.js'
19
+ import Router, {
20
+ registerRouterView,
21
+ state as routerState,
22
+ unregisterRouterView,
23
+ } from '../router/router.js'
20
24
  import symbols from '../lib/symbols.js'
21
25
  import Focus from '../focus/focus.js'
22
26
 
23
- let hashchangeHandler = null
24
-
25
27
  /** @typedef {{ $input?: (event: any) => boolean, $focus?: (event: any) => void }} RouterViewParent */
26
28
 
27
29
  export default () =>
@@ -123,24 +125,42 @@ export default () =>
123
125
  effects: [],
124
126
  context: {},
125
127
  },
128
+ props: {
129
+ name: '',
130
+ active: true,
131
+ },
126
132
  state() {
127
133
  return {
128
134
  activeView: null,
135
+ history: [],
129
136
  }
130
137
  },
138
+ watch: {
139
+ active(v, old) {
140
+ if (v === true && old === false) {
141
+ Router.navigate.apply(this)
142
+ }
143
+ },
144
+ },
131
145
  hooks: {
132
146
  async ready() {
147
+ registerRouterView(this)
148
+
133
149
  const parent = this[symbols.parent]
134
150
  if (parent && parent[symbols.routerHooks] && parent[symbols.routerHooks].init) {
135
151
  await parent[symbols.routerHooks].init.apply(parent)
136
152
  }
153
+ this.hashchangeHandler = () => Router.navigate.apply(this)
154
+
155
+ if (this.active === true) {
156
+ Router.navigate.apply(this)
157
+ }
137
158
 
138
- hashchangeHandler = () => Router.navigate.apply(this)
139
- Router.navigate.apply(this)
140
- window.addEventListener('hashchange', hashchangeHandler)
159
+ window.addEventListener('hashchange', this.hashchangeHandler)
141
160
  },
142
161
  destroy() {
143
- window.removeEventListener('hashchange', hashchangeHandler, false)
162
+ unregisterRouterView(this)
163
+ window.removeEventListener('hashchange', this.hashchangeHandler, false)
144
164
  },
145
165
  focus() {
146
166
  if (this.activeView && Focus.get() === this) {
@@ -81,6 +81,7 @@ export default {
81
81
  },
82
82
  input(key, event) {
83
83
  if (state.navigating === true) return
84
+ if (focusedComponent === null) return
84
85
 
85
86
  const componentWithInputEvent = getComponentWithInputEvent(focusedComponent, key)
86
87
 
@@ -100,176 +100,258 @@ const skipNonCode = (text, pos) => {
100
100
  return p
101
101
  }
102
102
 
103
- export default (code) => {
104
- // Find component declarations
105
- const componentRegex =
106
- /Blits\.(Component|Application)\s*\(\s*(?:'[^']*'|"[^"]*")?\s*(?:,\s*)?({[\s\S]*?})\s*\)/g
103
+ // Skip a non-method computed member (`key: value`) and return the position after it.
104
+ const skipComputedMember = (text, pos) => {
105
+ const keyMatch = /^(\w+)\s*:\s*/.exec(text.substring(pos))
106
+ if (!keyMatch) return -1
107
+
108
+ let p = pos + keyMatch[0].length
109
+ p = skipNonCode(text, p)
110
+ if (p >= text.length - 1) return p
111
+
112
+ if (text.startsWith('function', p)) {
113
+ const open = text.indexOf('{', p)
114
+ if (open === -1) return -1
115
+ const close = findMatchingBrace(text, open)
116
+ return close === -1 ? -1 : close
117
+ }
118
+
119
+ let depth = 0
120
+ let inString = false
121
+ let stringChar = ''
122
+
123
+ for (; p < text.length - 1; p++) {
124
+ const char = text[p]
125
+
126
+ if (!inString && (char === '"' || char === "'" || char === '`')) {
127
+ inString = true
128
+ stringChar = char
129
+ } else if (inString && char === stringChar && text[p - 1] !== '\\') {
130
+ inString = false
131
+ } else if (!inString && char === '/' && text[p + 1] === '/') {
132
+ const nextNewline = text.indexOf('\n', p + 2)
133
+ p = nextNewline === -1 ? text.length - 2 : nextNewline
134
+ } else if (!inString && char === '/' && text[p + 1] === '*') {
135
+ const end = text.indexOf('*/', p + 2)
136
+ p = end === -1 ? text.length - 2 : end + 1
137
+ } else if (!inString) {
138
+ if (char === '{' || char === '(' || char === '[') depth++
139
+ else if (char === '}' || char === ')' || char === ']') depth--
140
+ else if (char === ',' && depth === 0) return p
141
+ }
142
+ }
143
+
144
+ return p
145
+ }
146
+
147
+ // Find the opening `{` of the component config object after Blits.Component( / Application(
148
+ const findConfigBraceStart = (text, openParenPos) => {
149
+ let p = openParenPos + 1
150
+ p = skipNonCode(text, p)
151
+ if (p >= text.length) return -1
152
+
153
+ if (text[p] === '{') return p
154
+
155
+ if (text[p] === "'" || text[p] === '"' || text[p] === '`') {
156
+ const quote = text[p]
157
+ p++
158
+ while (p < text.length) {
159
+ if (text[p] === quote && text[p - 1] !== '\\') {
160
+ p++
161
+ break
162
+ }
163
+ p++
164
+ }
165
+ p = skipNonCode(text, p)
166
+ if (p < text.length && text[p] === '{') return p
167
+ }
168
+
169
+ return -1
170
+ }
171
+
172
+ const processComputedBlock = (configObject) => {
107
173
  const modifications = []
108
174
  const commentText = ' /* auto-generated reactivity guard */ '
109
175
 
110
- let match
111
- while ((match = componentRegex.exec(code)) !== null) {
112
- const configObject = match[2]
113
-
114
- // Find computed section with proper brace balancing
115
- const computedKeywordMatch = /computed\s*:\s*{/.exec(configObject)
116
-
117
- if (computedKeywordMatch) {
118
- // Find start position of the computed block
119
- const computedStart = computedKeywordMatch.index + computedKeywordMatch[0].length - 1 // Position of the opening brace
120
-
121
- // Find the matching closing brace for computed object in config
122
- const computedEnd = findComputedObjectEnd(configObject, computedStart)
123
-
124
- if (computedEnd !== -1) {
125
- const computedObj = configObject.substring(computedStart, computedEnd)
126
- const computedBlockStart = computedKeywordMatch.index
127
- const computedBlockEnd = computedEnd
128
-
129
- // Process each computed property
130
- const computedProps = []
131
- let currentPos = 1 // Start after the opening brace
132
-
133
- // Process computed properties content
134
- // intentionally not using regexes to extract functions and their content
135
- // using cursor-based parsing gives better accuracy
136
- while (currentPos < computedObj.length - 1) {
137
- // -1 to exclude the closing brace
138
- // Skip whitespace/comments so we don't match commented-out computed functions
139
- currentPos = skipNonCode(computedObj, currentPos)
140
- if (currentPos >= computedObj.length - 1) break
141
-
142
- // Find the next function name (computed properties are `name() { ... }`)
143
- const functionNameMatch = /^(\w+)\s*\(\)\s*/.exec(computedObj.substring(currentPos))
144
- if (!functionNameMatch) break
145
-
146
- const funcName = functionNameMatch[1]
147
- const funcNamePos = currentPos
148
-
149
- // Find opening brace for this function
150
- const afterSignaturePos = funcNamePos + functionNameMatch[0].length
151
- const openingBracePos = computedObj.indexOf('{', afterSignaturePos)
152
- if (openingBracePos === -1) break
153
-
154
- // Find closing brace for individual computed function
155
- const closingBracePos = findComputedFunctionEnd(computedObj, openingBracePos)
156
-
157
- if (closingBracePos === -1) {
158
- break
159
- }
160
-
161
- // Extract the entire function and its body
162
- // This is better and more accurate than using regex to avoid nested braces
163
- const functionStartPos = funcNamePos
164
- const functionEndPos = closingBracePos
165
- const fullFunction = computedObj.substring(functionStartPos, functionEndPos)
166
- const functionBody = computedObj
167
- .substring(openingBracePos + 1, closingBracePos - 1)
168
- .trim()
169
-
170
- computedProps.push({
171
- name: funcName,
172
- fullText: fullFunction,
173
- body: functionBody,
174
- startPos: functionStartPos,
175
- endPos: functionEndPos,
176
- })
177
-
178
- // Move past this function
179
- currentPos = closingBracePos
176
+ const computedKeywordMatch = /computed\s*:\s*{/.exec(configObject)
177
+
178
+ if (computedKeywordMatch) {
179
+ // Find start position of the computed block
180
+ const computedStart = computedKeywordMatch.index + computedKeywordMatch[0].length - 1 // Position of the opening brace
181
+
182
+ // Find the matching closing brace for computed object in config
183
+ const computedEnd = findComputedObjectEnd(configObject, computedStart)
184
+
185
+ if (computedEnd !== -1) {
186
+ const computedObj = configObject.substring(computedStart, computedEnd)
187
+ const computedBlockStart = computedKeywordMatch.index
188
+ const computedBlockEnd = computedEnd
189
+
190
+ // Process each computed property
191
+ const computedProps = []
192
+ let currentPos = 1 // Start after the opening brace
193
+
194
+ // Process computed properties content
195
+ // intentionally not using regexes to extract functions and their content
196
+ // using cursor-based parsing gives better accuracy
197
+ while (currentPos < computedObj.length - 1) {
198
+ // -1 to exclude the closing brace
199
+ // Skip whitespace/comments so we don't match commented-out computed functions
200
+ currentPos = skipNonCode(computedObj, currentPos)
201
+ if (currentPos >= computedObj.length - 1) break
202
+
203
+ // Find the next function name (computed properties are `name() { ... }`)
204
+ const functionNameMatch = /^(\w+)\s*\(\)\s*/.exec(computedObj.substring(currentPos))
205
+ if (!functionNameMatch) {
206
+ const nextPos = skipComputedMember(computedObj, currentPos)
207
+ if (nextPos === -1) break
208
+ currentPos = nextPos
209
+ continue
180
210
  }
181
211
 
182
- // Store the original computed object for later replacement
183
- const originalComputedObj = configObject.substring(computedBlockStart, computedBlockEnd)
184
- const computedObjOffsetInOriginal = computedStart - computedBlockStart
185
- const computedObjEndInOriginal = computedObjOffsetInOriginal + computedObj.length
186
-
187
- let modifiedComputedObj = originalComputedObj
188
- let hasChanges = false
189
-
190
- const replacements = []
191
-
192
- for (const prop of computedProps) {
193
- const thisRefs = extractThisReferences(prop.body)
194
-
195
- if (thisRefs.size > 0) {
196
- // Skip if already modified
197
- if (
198
- prop.body.includes(commentText.trim()) ||
199
- Array.from(thisRefs).some((ref) => {
200
- if (prop.body.startsWith(ref)) return true
201
- // stripped && guard: body starts with the first segment, e.g. 'this.media &&'
202
- const firstSegEnd = ref.indexOf('.', 5)
203
- return firstSegEnd !== -1 && prop.body.startsWith(ref.substring(0, firstSegEnd))
204
- })
205
- ) {
206
- continue
207
- }
208
-
209
- // reactivity code
210
- const refCode = Array.from(thisRefs)
211
- .map((ref) => `${toSafeRef(ref)};`)
212
- .join(' ')
213
-
214
- // replacement with the same whitespace
215
- const openBraceIndex = prop.fullText.indexOf('{')
216
-
217
- if (openBraceIndex !== -1) {
218
- // Extract indentation from the original function body
219
- const indentMatch = prop.body.match(/^(\s+)/)
220
- const indent = indentMatch ? indentMatch[1] + ' ' : ' ' // Default to 4 spaces if no indent found
221
-
222
- const modifiedPropText =
223
- prop.fullText.substring(0, openBraceIndex + 1) +
224
- '\n' +
225
- indent +
226
- commentText +
227
- '\n' +
228
- indent +
229
- refCode +
230
- '\n' +
231
- indent +
232
- prop.body +
233
- '\n' +
234
- prop.fullText.substring(0, openBraceIndex).match(/^(\s*)/)[0] +
235
- '}'
236
-
237
- replacements.push({
238
- start: prop.startPos,
239
- end: prop.endPos,
240
- text: modifiedPropText,
241
- })
242
- }
243
- }
212
+ const funcName = functionNameMatch[1]
213
+ const funcNamePos = currentPos
214
+
215
+ // Find opening brace for this function
216
+ const afterSignaturePos = funcNamePos + functionNameMatch[0].length
217
+ const openingBracePos = computedObj.indexOf('{', afterSignaturePos)
218
+ if (openingBracePos === -1) break
219
+
220
+ // Find closing brace for individual computed function
221
+ const closingBracePos = findComputedFunctionEnd(computedObj, openingBracePos)
222
+
223
+ if (closingBracePos === -1) {
224
+ break
244
225
  }
245
226
 
246
- if (replacements.length > 0) {
247
- let modifiedComputedInner = computedObj
227
+ // Extract the entire function and its body
228
+ // This is better and more accurate than using regex to avoid nested braces
229
+ const functionStartPos = funcNamePos
230
+ const functionEndPos = closingBracePos
231
+ const fullFunction = computedObj.substring(functionStartPos, functionEndPos)
232
+ const functionBody = computedObj.substring(openingBracePos + 1, closingBracePos - 1).trim()
233
+
234
+ computedProps.push({
235
+ name: funcName,
236
+ fullText: fullFunction,
237
+ body: functionBody,
238
+ startPos: functionStartPos,
239
+ endPos: functionEndPos,
240
+ })
241
+
242
+ // Move past this function
243
+ currentPos = closingBracePos
244
+ }
248
245
 
249
- replacements.sort((a, b) => b.start - a.start)
250
- for (const r of replacements) {
251
- modifiedComputedInner =
252
- modifiedComputedInner.slice(0, r.start) + r.text + modifiedComputedInner.slice(r.end)
246
+ // Store the original computed object for later replacement
247
+ const originalComputedObj = configObject.substring(computedBlockStart, computedBlockEnd)
248
+ const computedObjOffsetInOriginal = computedStart - computedBlockStart
249
+ const computedObjEndInOriginal = computedObjOffsetInOriginal + computedObj.length
250
+
251
+ let modifiedComputedObj = originalComputedObj
252
+ let hasChanges = false
253
+
254
+ const replacements = []
255
+
256
+ for (const prop of computedProps) {
257
+ const thisRefs = extractThisReferences(prop.body)
258
+
259
+ if (thisRefs.size > 0) {
260
+ // Skip if already modified
261
+ if (
262
+ prop.body.includes(commentText.trim()) ||
263
+ Array.from(thisRefs).some((ref) => {
264
+ if (prop.body.startsWith(ref)) return true
265
+ // stripped && guard: body starts with the first segment, e.g. 'this.media &&'
266
+ const firstSegEnd = ref.indexOf('.', 5)
267
+ return firstSegEnd !== -1 && prop.body.startsWith(ref.substring(0, firstSegEnd))
268
+ })
269
+ ) {
270
+ continue
253
271
  }
254
272
 
255
- modifiedComputedObj =
256
- originalComputedObj.slice(0, computedObjOffsetInOriginal) +
257
- modifiedComputedInner +
258
- originalComputedObj.slice(computedObjEndInOriginal)
259
- hasChanges = true
273
+ // reactivity code
274
+ const refCode = Array.from(thisRefs)
275
+ .map((ref) => `${toSafeRef(ref)};`)
276
+ .join(' ')
277
+
278
+ // replacement with the same whitespace
279
+ const openBraceIndex = prop.fullText.indexOf('{')
280
+
281
+ if (openBraceIndex !== -1) {
282
+ // Extract indentation from the original function body
283
+ const indentMatch = prop.body.match(/^(\s+)/)
284
+ const indent = indentMatch ? indentMatch[1] + ' ' : ' ' // Default to 4 spaces if no indent found
285
+
286
+ const modifiedPropText =
287
+ prop.fullText.substring(0, openBraceIndex + 1) +
288
+ '\n' +
289
+ indent +
290
+ commentText +
291
+ '\n' +
292
+ indent +
293
+ refCode +
294
+ '\n' +
295
+ indent +
296
+ prop.body +
297
+ '\n' +
298
+ prop.fullText.substring(0, openBraceIndex).match(/^(\s*)/)[0] +
299
+ '}'
300
+
301
+ replacements.push({
302
+ start: prop.startPos,
303
+ end: prop.endPos,
304
+ text: modifiedPropText,
305
+ })
306
+ }
260
307
  }
308
+ }
309
+
310
+ if (replacements.length > 0) {
311
+ let modifiedComputedInner = computedObj
261
312
 
262
- if (hasChanges) {
263
- // Store the modification of the entire computed object
264
- modifications.push({
265
- original: originalComputedObj,
266
- replacement: modifiedComputedObj,
267
- })
313
+ replacements.sort((a, b) => b.start - a.start)
314
+ for (const r of replacements) {
315
+ modifiedComputedInner =
316
+ modifiedComputedInner.slice(0, r.start) + r.text + modifiedComputedInner.slice(r.end)
268
317
  }
318
+
319
+ modifiedComputedObj =
320
+ originalComputedObj.slice(0, computedObjOffsetInOriginal) +
321
+ modifiedComputedInner +
322
+ originalComputedObj.slice(computedObjEndInOriginal)
323
+ hasChanges = true
324
+ }
325
+
326
+ if (hasChanges) {
327
+ modifications.push({
328
+ original: originalComputedObj,
329
+ replacement: modifiedComputedObj,
330
+ })
269
331
  }
270
332
  }
271
333
  }
272
334
 
335
+ return modifications
336
+ }
337
+
338
+ export default (code) => {
339
+ const componentOpenRegex = /Blits\.(Component|Application)\s*\(/g
340
+ const modifications = []
341
+
342
+ let match
343
+ while ((match = componentOpenRegex.exec(code)) !== null) {
344
+ const openParenPos = match.index + match[0].length - 1
345
+ const configStart = findConfigBraceStart(code, openParenPos)
346
+ if (configStart === -1) continue
347
+
348
+ const configEnd = findMatchingBrace(code, configStart)
349
+ if (configEnd === -1) continue
350
+
351
+ const configObject = code.substring(configStart, configEnd + 1)
352
+ modifications.push(...processComputedBlock(configObject))
353
+ }
354
+
273
355
  // Apply all modifications at once, from last to first to preserve positions
274
356
  if (modifications.length > 0) {
275
357
  let modifiedCode = code
@@ -15,7 +15,7 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
- import { getHash, isObject, isString, matchHash } from './utils.js'
18
+ import { getHash, isObject, isString, matchHash, sameRouteObject, setHash } from './utils.js'
19
19
  import { reactive } from '../lib/reactivity/reactive.js'
20
20
 
21
21
  import symbols from '../lib/symbols.js'
@@ -52,7 +52,8 @@ import Settings from '../settings.js'
52
52
  */
53
53
 
54
54
  /** @type {Route} */
55
- export let currentRoute
55
+ export let currentRoute = {}
56
+
56
57
  export const state = reactive(
57
58
  {
58
59
  path: '',
@@ -76,6 +77,8 @@ export const state = reactive(
76
77
  */
77
78
 
78
79
  const history = []
80
+ const routerViews = new Set()
81
+ let singleRouterView = null
79
82
 
80
83
  let overrideOptions = {}
81
84
  let navigationData = {}
@@ -105,12 +108,14 @@ export const navigate = async function () {
105
108
  if (!this[symbols.parent][symbols.routes] || this[symbols.parent][symbols.routes].length === 0)
106
109
  return
107
110
 
111
+ if (this.history === undefined) this.history = []
112
+
108
113
  state.navigating = true
109
114
 
110
115
  Announcer.stop()
111
116
  Announcer.clear()
112
117
 
113
- const hash = getHash(location.hash)
118
+ const hash = getHash(location.hash, this.name)
114
119
  // try to find the route
115
120
  let route = matchHash(hash, this[symbols.parent][symbols.routes], overrideOptions, navigationData)
116
121
 
@@ -126,36 +131,46 @@ export const navigate = async function () {
126
131
  return
127
132
  }
128
133
 
134
+ // sameRouteObject is too simple check for now!
135
+ if (this.currentRoute !== undefined && sameRouteObject(route, this.currentRoute)) {
136
+ state.navigating = false
137
+ return
138
+ }
139
+
129
140
  let reuse = false
130
- let previousRoute = currentRoute
131
- currentRoute = route
132
- const currentPath = currentRoute.path
141
+ this.previousRoute = this.currentRoute
142
+ this.currentRoute = route
143
+ const currentPath = this.currentRoute.path
133
144
 
134
145
  // execute before each hook
135
- const beforeEachResult = await executeBeforeHook(
146
+ const beforeEachResult = await executeBeforeHook.call(
147
+ this,
136
148
  this[symbols.parent][symbols.routerHooks],
137
149
  'beforeEach',
138
150
  this[symbols.parent],
139
151
  route,
140
- previousRoute,
152
+ this.previousRoute,
141
153
  currentPath
142
154
  )
143
155
  if (beforeEachResult === false) {
144
156
  preventHashChangeNavigation = false
157
+ state.navigating = false
145
158
  return
146
159
  }
147
160
 
148
161
  // execute before route hook
149
- const beforeResult = await executeBeforeHook(
162
+ const beforeResult = await executeBeforeHook.call(
163
+ this,
150
164
  route.hooks,
151
165
  'before',
152
166
  this[symbols.parent],
153
167
  route,
154
- previousRoute,
168
+ this.previousRoute,
155
169
  currentPath
156
170
  )
157
171
  if (beforeResult === false) {
158
172
  preventHashChangeNavigation = false
173
+ state.navigating = false
159
174
  return
160
175
  }
161
176
 
@@ -165,18 +180,18 @@ export const navigate = async function () {
165
180
  // FIX: use truthy check instead of `!== undefined` because matchHash()
166
181
  // can return `false`, which survives `!== undefined` but has no `.options`.
167
182
  if (
168
- previousRoute &&
169
- previousRoute.options &&
170
- previousRoute.options.inHistory === true &&
183
+ this.previousRoute &&
184
+ this.previousRoute.options &&
185
+ this.previousRoute.options.inHistory === true &&
171
186
  navigatingBack === false
172
187
  ) {
173
- history.push(previousRoute)
188
+ this.history.push(this.previousRoute)
174
189
  }
175
190
 
176
191
  // a transition can be a function returning a dynamic transition object
177
192
  // based on current and previous route
178
193
  if (typeof route.transition === 'function') {
179
- route.transition = route.transition(previousRoute, route)
194
+ route.transition = route.transition(this.previousRoute, route)
180
195
  }
181
196
 
182
197
  /** @type {import('../engines/L3/element.js').BlitsElement} */
@@ -201,10 +216,10 @@ export const navigate = async function () {
201
216
  // see if the component of the previous route can be reused for the
202
217
  // current route
203
218
  if (
204
- previousRoute &&
219
+ this.previousRoute &&
205
220
  route.options.reuseComponent === true &&
206
221
  route.options.keepAlive !== true &&
207
- route.component === previousRoute.component
222
+ route.component === this.previousRoute.component
208
223
  ) {
209
224
  reuse = true
210
225
  view = this[symbols.children][this[symbols.children].length - 1]
@@ -288,28 +303,30 @@ export const navigate = async function () {
288
303
  // apply out out transition on previous view if available, unless
289
304
  // we're reusing the prvious page component
290
305
  // FIX: truthy guard — previousRoute can be `false` (see history-push comment above).
291
- if (previousRoute && reuse === false) {
306
+ if (this.previousRoute && reuse === false) {
292
307
  // only animate when there is a previous route
293
308
  shouldAnimate = true
294
309
  let oldView = this[symbols.children].splice(1, 1).pop()
295
310
  if (oldView) {
296
- executeTransition(previousRoute.transition.out, oldView[symbols.holder], true)
311
+ executeTransition(this.previousRoute.transition.out, oldView[symbols.holder], true)
297
312
 
298
313
  // Resolve effective keepAlive: runtime override from $router.to() takes precedence
299
314
  // over the static route config option
315
+ const hasOverrideOptions = overrideOptions && typeof overrideOptions === 'object'
300
316
  const keepAlive =
301
- overrideOptions.keepAlive !== undefined
317
+ hasOverrideOptions && overrideOptions.keepAlive !== undefined
302
318
  ? overrideOptions.keepAlive
303
- : previousRoute.options && previousRoute.options.keepAlive
319
+ : this.previousRoute.options && this.previousRoute.options.keepAlive
304
320
 
305
321
  // cache the page when it's as 'keepAlive' instead of destroying
306
322
  if (
307
323
  navigatingBack === false &&
308
324
  keepAlive === true &&
309
- previousRoute.options &&
310
- previousRoute.options.inHistory === true
325
+ this.previousRoute &&
326
+ this.previousRoute.options &&
327
+ this.previousRoute.options.inHistory === true
311
328
  ) {
312
- const historyItem = history[history.length - 1]
329
+ const historyItem = this.history[this.history.length - 1]
313
330
  if (historyItem !== undefined) {
314
331
  historyItem.view = oldView
315
332
  historyItem.focus = previousFocus
@@ -321,7 +338,7 @@ export const navigate = async function () {
321
338
  * 2. Navigating back, and the previous route is configured with "keep alive" set to true.
322
339
  * 3. Navigating back, and the previous route is not configured with "keep alive" set to true.
323
340
  */
324
- if (previousRoute.options && (keepAlive !== true || navigatingBack === true)) {
341
+ if (this.previousRoute.options && (keepAlive !== true || navigatingBack === true)) {
325
342
  oldView.destroy()
326
343
  oldView = null
327
344
  }
@@ -339,11 +356,11 @@ export const navigate = async function () {
339
356
  'afterEach',
340
357
  this[symbols.parent],
341
358
  route,
342
- previousRoute
359
+ this.previousRoute
343
360
  )
344
361
 
345
362
  // execute after route Hook
346
- await executeAfterHook(route.hooks, 'after', this[symbols.parent], route, previousRoute)
363
+ await executeAfterHook(route.hooks, 'after', this[symbols.parent], route, this.previousRoute)
347
364
 
348
365
  // Clear module-level variables after removeView has consumed them.
349
366
  // Placed here so it executes for all navigation flows, not only when
@@ -397,9 +414,10 @@ const executeBeforeHook = async function (
397
414
  }
398
415
  } catch (error) {
399
416
  Log.error(`Error or Rejected Promise in "${hookName}" Hook`, error)
400
- if (history.length > 0) {
417
+ if (this.history.length > 0) {
401
418
  preventHashChangeNavigation = true
402
419
  currentRoute = previousRoute
420
+ /// hmmmm
403
421
  window.history.back()
404
422
  navigatingBack = false
405
423
  state.navigating = false
@@ -413,12 +431,14 @@ const executeBeforeHook = async function (
413
431
  return false
414
432
  }
415
433
  // If the resolved result is false, cancel navigation
416
- if (result === false && history.length > 0) {
417
- preventHashChangeNavigation = true
418
- currentRoute = previousRoute
419
- window.history.back()
420
- navigatingBack = false
421
- state.navigating = false
434
+ if (result === false) {
435
+ if (this.history.length > 0) {
436
+ preventHashChangeNavigation = true
437
+ currentRoute = previousRoute
438
+ window.history.back()
439
+ navigatingBack = false
440
+ state.navigating = false
441
+ }
422
442
  return false
423
443
  }
424
444
  }
@@ -475,20 +495,51 @@ export const to = (path, data = {}, options = {}) => {
475
495
  navigationData = data
476
496
  overrideOptions = options
477
497
 
478
- location.hash = path.replace(/^#/, '')
498
+ setHash(path)
499
+ }
500
+
501
+ export const toRouterView = (routerView, path, data = {}, options = {}) => {
502
+ navigationData = data
503
+ overrideOptions = options
504
+
505
+ setHash(path, routerView.name)
506
+ }
507
+
508
+ export const registerRouterView = (routerView) => {
509
+ routerViews.add(routerView)
510
+ singleRouterView = routerViews.size === 1 ? routerView : null
511
+ }
512
+
513
+ export const unregisterRouterView = (routerView) => {
514
+ routerViews.delete(routerView)
515
+ singleRouterView = routerViews.size === 1 ? routerViews.values().next().value : null
516
+ }
517
+
518
+ export const getRegisteredRouterView = (name) => {
519
+ for (const routerView of routerViews) {
520
+ if (routerView.name === name) return routerView
521
+ }
522
+ return null
523
+ }
524
+
525
+ export const getRegisteredRouterViewsCount = () => routerViews.size
526
+
527
+ export const getSingleRegisteredRouterView = () => {
528
+ return singleRouterView
479
529
  }
480
530
 
481
531
  export const back = function () {
482
- const route = history.pop()
483
- if (route && currentRoute !== route) {
532
+ if (this.history === undefined) return
533
+ const route = this.history.pop()
534
+ if (route && this.currentRoute !== route) {
484
535
  // set indicator that we are navigating back (to prevent adding page to history stack)
485
536
  navigatingBack = true
486
537
  navigatingBackTo = route
487
- to(route.hash, route.data, route.options)
538
+ toRouterView(this, route.hash, route.data, route.options)
488
539
  return true
489
540
  }
490
541
 
491
- const backtrack = (currentRoute && currentRoute.options.backtrack) || false
542
+ const backtrack = (this.currentRoute && this.currentRoute.options.backtrack) || false
492
543
 
493
544
  // If we deeplink to a page without backtrack
494
545
  // we we let the RouterView handle back
@@ -497,7 +548,7 @@ export const back = function () {
497
548
  }
498
549
 
499
550
  const hashEnd = /(\/:?[\w%\s-]+)$/
500
- let path = currentRoute.path
551
+ let path = this.currentRoute.path
501
552
 
502
553
  let level = path.split('/').length
503
554
 
@@ -520,7 +571,7 @@ export const back = function () {
520
571
  )
521
572
 
522
573
  if (route && backtrack) {
523
- to(route.path, route.data, route.options)
574
+ toRouterView(this, route.path, route.data, route.options)
524
575
  return true
525
576
  }
526
577
  }
@@ -4,16 +4,85 @@ import { default as fadeInFadeOutTransition } from './transitions/fadeInOut.js'
4
4
  * Get the current hash
5
5
  * @returns {Hash}
6
6
  */
7
- export const getHash = (hash) => {
7
+ export const getHash = (hash, routerViewName = '') => {
8
8
  if (!hash) hash = '/'
9
- const hashParts = hash.replace(/^#/, '').split('?')
9
+
10
+ // split router views
11
+ const [main, ...named] = hash.split('|')
12
+
13
+ if (routerViewName !== '') {
14
+ const match = named.find((name) => name.startsWith(routerViewName + '='))
15
+ if (match !== undefined) {
16
+ hash = match.slice(routerViewName.length + 1)
17
+ } else {
18
+ hash = '/'
19
+ }
20
+ } else {
21
+ hash = main
22
+ }
23
+
24
+ const [path = '', query = ''] = hash.replace(/^#/, '').split('?')
10
25
  return {
11
- path: hashParts[0],
12
- queryParams: new URLSearchParams(hashParts[1]),
26
+ path: path,
27
+ queryParams: new URLSearchParams(query),
13
28
  hash: hash,
14
29
  }
15
30
  }
16
31
 
32
+ export const setHash = (path, routerViewName = '') => {
33
+ const cleanPath = (path || '').replace(/^#/, '')
34
+ const raw = (location.hash || '').replace(/^#/, '')
35
+
36
+ const parts = raw ? raw.split('|').filter(Boolean) : []
37
+
38
+ let newMain = '/'
39
+ const newNamed = {}
40
+
41
+ // Parse existing hash. The first segment is always the main route; only
42
+ // subsequent pipe-separated segments can be named router views.
43
+ for (let i = 0; i < parts.length; i++) {
44
+ const part = parts[i]
45
+ if (i === 0) {
46
+ newMain = part
47
+ continue
48
+ }
49
+
50
+ const eqIndex = part.indexOf('=')
51
+
52
+ // named route: sidebar=/search
53
+ if (eqIndex > 0) {
54
+ const name = part.slice(0, eqIndex)
55
+ const value = part.slice(eqIndex + 1)
56
+ if (value) newNamed[name] = value
57
+ continue
58
+ }
59
+
60
+ // unnamed/main route
61
+ if (part) {
62
+ newMain = part
63
+ }
64
+ }
65
+
66
+ // Update target router view
67
+ if (routerViewName === '') {
68
+ newMain = cleanPath || '/'
69
+ } else {
70
+ if (cleanPath) {
71
+ newNamed[routerViewName] = cleanPath
72
+ } else {
73
+ delete newNamed[routerViewName]
74
+ }
75
+ }
76
+
77
+ // Rebuild hash: main always first
78
+ let newHash = newMain || '/'
79
+
80
+ for (const [name, value] of Object.entries(newNamed)) {
81
+ newHash += `|${name}=${value}`
82
+ }
83
+
84
+ location.hash = newHash
85
+ }
17
86
  export const normalizePath = (path) => {
18
87
  return (
19
88
  path
@@ -60,17 +129,25 @@ const defaultOptions = {
60
129
  }
61
130
 
62
131
  export const makeRouteObject = (route, overrides, overrideOptions, navigationData) => {
63
- // FIX: exclude keepAlive from the destination route options. Unlike other
64
- // overrides, keepAlive applies to the route being LEFT, not the destination.
65
- // It is consumed by removeView() instead.
66
- const { keepAlive: _keepAlive, ...destOverrides } = overrideOptions // eslint-disable-line no-unused-vars
132
+ const options = { ...defaultOptions, ...route.options }
133
+
134
+ // keepAlive applies to the route being left, so do not merge it into the destination.
135
+ if (isObject(overrideOptions)) {
136
+ const keys = Object.keys(overrideOptions)
137
+ for (let i = 0; i < keys.length; i++) {
138
+ const key = keys[i]
139
+ if (key !== 'keepAlive') {
140
+ options[key] = overrideOptions[key]
141
+ }
142
+ }
143
+ }
67
144
 
68
145
  const cleanRoute = {
69
146
  hash: overrides.hash,
70
147
  path: route.path,
71
148
  component: route.component,
72
149
  transition: 'transition' in route ? route.transition : fadeInFadeOutTransition,
73
- options: { ...defaultOptions, ...route.options, ...destOverrides },
150
+ options,
74
151
  announce: route.announce || false,
75
152
  hooks: route.hooks || {},
76
153
  data: { ...route.data, ...navigationData, ...overrides.queryParams },
@@ -156,3 +233,13 @@ export const matchHash = (
156
233
  // @ts-ignore - Remove me when we have a better way to handle this
157
234
  return matchingRoute
158
235
  }
236
+
237
+ export const sameRouteObject = (route1, route2) => {
238
+ if (route1 === null) return false
239
+ if (route2 === null) return false
240
+
241
+ // this is too simple a check for now
242
+ if (route1.hash !== route2.hash) return false
243
+
244
+ return true
245
+ }