@lightningjs/blits 2.14.0 → 2.16.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.
@@ -20,7 +20,6 @@ import { WebGlCoreRenderer, SdfTextRenderer } from '@lightningjs/renderer/webgl'
20
20
  import { CanvasCoreRenderer, CanvasTextRenderer } from '@lightningjs/renderer/canvas'
21
21
  import { Inspector } from '@lightningjs/renderer/inspector'
22
22
 
23
- import { Log } from '../../lib/log.js'
24
23
  import { SCREEN_RESOLUTIONS, RENDER_QUALITIES } from '../../constants.js'
25
24
  import colors from '../../lib/colors/colors.js'
26
25
  import fontLoader from './fontLoader.js'
@@ -85,7 +84,7 @@ const textureMemorySettings = (settings) => {
85
84
  * @param {Partial<import('../../launch.js').BlitsSettings>} [settings] - The settings for the renderer
86
85
  *
87
86
  */
88
- export default (App, target, settings = {}) => {
87
+ export default (target, settings = {}) => {
89
88
  const screenHeight = settings.platform ? settings.platform.screenHeight : platform.screenHeight
90
89
  const hardwareConcurrency = settings.platform
91
90
  ? settings.platform.hardwareConcurrency
@@ -125,21 +124,8 @@ export default (App, target, settings = {}) => {
125
124
  target
126
125
  )
127
126
 
128
- const initApp = () => {
129
- let app = App()
130
- app.quit = () => {
131
- Log.info('Closing App')
132
- app.destroy()
133
- app = null
134
- renderer = null
135
- }
136
- }
137
-
138
127
  shaderLoader()
139
128
  fontLoader()
140
- // Defer app initialization until after the current call stack is cleared,
141
- // allowing the renderer to finish setting up before the app starts creating components
142
- Promise.resolve().then(initApp)
143
129
 
144
130
  return renderer
145
131
  }
@@ -68,6 +68,10 @@ export default {
68
68
  while (i--) {
69
69
  // don't unfocus when part of the new focus chain
70
70
  if (newFocusChain.indexOf(focusChain[i]) > -1) break
71
+ // only unfocus components that actually have focus
72
+ // ($hasFocus, not the lifecycle state which is shared with hover/enter/exit)
73
+ const componentState = focusChain[i][symbols.state]
74
+ if (componentState !== undefined && componentState.$hasFocus === false) continue
71
75
  focusChain[i][symbols.lifecycle].state = 'unfocus'
72
76
  }
73
77
  focusChain = newFocusChain
package/src/keymap.js ADDED
@@ -0,0 +1,27 @@
1
+ /*
2
+ * Copyright 2023 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ /**
19
+ * Merged keyMap (default + custom settings).
20
+ * Updated during application initialization and runtime settings changes.
21
+ * @type {Object<string, string>}
22
+ */
23
+ export let keyMap = {}
24
+
25
+ export const setKeyMap = (value) => {
26
+ keyMap = value
27
+ }
package/src/launch.js CHANGED
@@ -18,7 +18,6 @@
18
18
  import Settings from './settings.js'
19
19
  import { initLog, Log } from './lib/log.js'
20
20
  import engine from './engine.js'
21
- import blitsPackageInfo from '../package.json' with { type: 'json' }
22
21
  import { configurePlatform } from './platform.js'
23
22
  import profiler from './lib/profiler.js'
24
23
 
@@ -52,20 +51,6 @@ const isDev = typeof __DEV__ !== 'undefined' && __DEV__
52
51
  export let renderer = {}
53
52
  export const stage = {}
54
53
 
55
- async function rendererVersion() {
56
- let rendererPackageInfo
57
- try {
58
- // Dynamically import the renderer package.json
59
- rendererPackageInfo = await import('../../renderer/package.json')
60
- if (rendererPackageInfo !== undefined) {
61
- return rendererPackageInfo.version
62
- }
63
- } catch (e) {
64
- // Fallback to renderer version in dependencies
65
- return blitsPackageInfo.dependencies['@lightningjs/renderer']
66
- }
67
- }
68
-
69
54
  /**
70
55
  * @typedef {Object} BlitsSettings
71
56
  * @property {number} [w] - Width of the Application
@@ -148,15 +133,28 @@ export default (App, target, settings) => {
148
133
  )
149
134
  }
150
135
 
151
- rendererVersion().then((v) => {
152
- Log.info('Blits Version ', blitsPackageInfo.version)
153
- Log.info('Renderer Version ', v)
154
- })
136
+ Log.info(
137
+ 'Blits version:',
138
+ typeof __BLITS_VERSION__ === 'undefined' ? 'unknown' : __BLITS_VERSION__
139
+ )
140
+ Log.info(
141
+ 'Renderer version:',
142
+ typeof __RENDERER_VERSION__ === 'undefined' ? 'unknown' : __RENDERER_VERSION__
143
+ )
155
144
 
156
145
  stage.element = engine.Element
157
146
 
158
- renderer = engine.Launch(App, target, settings)
147
+ renderer = engine.Launch(target, settings)
159
148
  if (isDev === true) {
160
149
  profiler.setRenderer(renderer)
161
150
  }
151
+
152
+ // Instantiate the app and wire up the quit function
153
+ let app = App()
154
+ app.quit = () => {
155
+ Log.info('Closing App')
156
+ app.destroy()
157
+ app = null
158
+ renderer = null
159
+ }
162
160
  }
@@ -87,6 +87,8 @@ export default function (templateObject = { children: [] }, devMode = false) {
87
87
  }, skips}
88
88
  `)
89
89
 
90
+ const renderCodeString = ctx.renderCode.join('\n')
91
+
90
92
  return {
91
93
  render: new Function(
92
94
  'parent',
@@ -96,7 +98,7 @@ export default function (templateObject = { children: [] }, devMode = false) {
96
98
  'effect',
97
99
  'getRaw',
98
100
  'Log',
99
- ctx.renderCode.join('\n')
101
+ renderCodeString
100
102
  ),
101
103
  effects: ctx.effectsCode.map(
102
104
  (code) =>
@@ -112,6 +114,7 @@ export default function (templateObject = { children: [] }, devMode = false) {
112
114
  )
113
115
  ),
114
116
  context: ctx.context,
117
+ usesHoverState: renderCodeString.includes('.$isHovered'),
115
118
  }
116
119
  }
117
120
 
@@ -212,6 +215,7 @@ const generateElementCode = function (
212
215
 
213
216
  if (options.holder) {
214
217
  renderCode.push(`elementConfigs[${counter}]['holder'] = true`)
218
+ renderCode.push(`elementConfigs[${counter}]['interactive'] = cmps[${counter}].needsInteractive`)
215
219
  }
216
220
 
217
221
  Object.keys(templateObject).forEach((key) => {
@@ -342,12 +346,31 @@ const generateComponentCode = function (
342
346
  ) {
343
347
  const renderCode = options.forceEffect ? this.effectsCode : this.renderCode
344
348
 
345
- renderCode.push(`
349
+ if ('is' in templateObject || ':is' in templateObject) {
350
+ const selectedComponent =
351
+ ':is' in templateObject
352
+ ? interpolate(templateObject[':is'], options.component)
353
+ : cast(templateObject.is, 'is', options.component)
354
+ // Resolve the selected factory before populating its holder and filtering props.
355
+ renderCode.push(`
356
+ componentType = ${selectedComponent} || '${templateObject[Symbol.for('componentType')]}'
357
+ cmps[${counter}] = typeof componentType === 'function'
358
+ ? componentType
359
+ : (context.components && context.components[componentType]) || components[componentType]
360
+ if (!cmps[${counter}]) {
361
+ throw new Error('Component "${templateObject[Symbol.for('componentType')]}" not found')
362
+ }
363
+ `)
364
+ } else {
365
+ renderCode.push(`
346
366
  cmps[${counter}] =
347
367
  (context.components && context.components['${
348
368
  templateObject[Symbol.for('componentType')]
349
369
  }']) || components['${templateObject[Symbol.for('componentType')]}']
350
370
  `)
371
+ }
372
+
373
+ renderCode.push(`if (cmps[${counter}].prepare !== undefined) cmps[${counter}].prepare()`)
351
374
 
352
375
  this.cleanupCode.push(`
353
376
  cmps[${counter}] = null
package/src/lib/hooks.js CHANGED
@@ -57,3 +57,7 @@ export const registerHooks = (hooks = {}, identifier) => {
57
57
  if (typeof hooks[hook] === 'function') cbs[identifier][hook] = hooks[hook]
58
58
  })
59
59
  }
60
+
61
+ export const hasHook = (hook, identifier) => {
62
+ return cbs[identifier] !== undefined && hook in cbs[identifier]
63
+ }
@@ -31,6 +31,11 @@ export default (source, filePath, mode) => {
31
31
  // Use MagicString to track changes
32
32
  const s = new MagicString(source)
33
33
 
34
+ // Find component definition start positions for scoping JS-layer checks
35
+ const componentStarts = [...source.matchAll(/Blits\.(Component|Application)\s*\(/g)]
36
+ .map((m) => m.index)
37
+ .sort((a, b) => a - b)
38
+
34
39
  for (const template of templates) {
35
40
  if (template[2]) {
36
41
  const templateContent = template[2]
@@ -52,10 +57,30 @@ export default (source, filePath, mode) => {
52
57
  // Generate the code
53
58
  const code = generator.call({ components: {} }, parsed, mode === 'development')
54
59
 
60
+ // Determine the component scope containing this template
61
+ let scopeStart = 0
62
+ let scopeEnd = source.length
63
+ for (let i = 0; i < componentStarts.length; i++) {
64
+ if (componentStarts[i] <= templateStartIndex) {
65
+ scopeStart = componentStarts[i]
66
+ scopeEnd = i + 1 < componentStarts.length ? componentStarts[i + 1] : source.length
67
+ }
68
+ }
69
+ const componentScope = source.slice(scopeStart, scopeEnd)
70
+
71
+ // Derive usesHoverState from both template ($$isHovered) and JS layer (this.$isHovered)
72
+ // Match strings first so URL/comment delimiters inside literals stay intact.
73
+ // Keep template literals too: their interpolations may read hover state.
74
+ const strippedScope = componentScope.replace(
75
+ /("(?:\\[\s\S]|[^"\\])*"|'(?:\\[\s\S]|[^'\\])*'|`(?:\\[\s\S]|[^`\\])*`)|\/\/[^\r\n]*|\/\*[\s\S]*?\*\//g,
76
+ (match, literal) => literal || ' '
77
+ )
78
+ const usesHoverState = code.usesHoverState || strippedScope.includes('this.$isHovered')
79
+
55
80
  // Insert the code in the component using the 'code' key, replacing the template key
56
81
  const replacement = `/* eslint-disable no-unused-vars */ \ncode: { render: ${code.render.toString()}, effects: [${code.effects.map(
57
82
  (fn) => fn.toString()
58
- )}], context: {}}`
83
+ )}], context: {}, usesHoverState: ${usesHoverState}}`
59
84
 
60
85
  // MagicString tracks all changes automatically, no offset needed
61
86
  s.overwrite(templateStartIndex, templateEndIndex, replacement)
@@ -15,8 +15,12 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
+ import symbols from '../symbols.js'
19
+
18
20
  let currentEffect = null
19
21
  let currentKey = null
22
+ // Write position for dependency-array reuse, restored after nested computed getters.
23
+ let computedDependencyIndex = 0
20
24
 
21
25
  // counter that tracks all pauseTracking invocations
22
26
  // to ensure nested array operations don't prematurely
@@ -33,20 +37,98 @@ export const resumeTracking = () => {
33
37
 
34
38
  const objectMap = new WeakMap()
35
39
  const effectDependenciesMap = new WeakMap()
40
+ const computedEffects = new WeakMap()
41
+ const effectQueue = new Set()
42
+ let pendingEffects = null
43
+
44
+ const computedRunner = (effectFn, key) => {
45
+ if (effectFn.computed === true) return effectFn
46
+ let runner = computedEffects.get(effectFn)
47
+ if (runner === undefined) {
48
+ runner = (force) => effect(effectFn, key, force)
49
+ computedEffects.set(effectFn, runner)
50
+ }
51
+ return runner
52
+ }
53
+
54
+ export const createComputed = (getter, component, key) => {
55
+ let value
56
+ let status = 'dirty'
57
+
58
+ const invalidate = (force) => {
59
+ if (status === 'dirty' || status === 'disposed') return
60
+ status = 'dirty'
61
+ trigger(component, key, force)
62
+ }
63
+ invalidate.computed = true
64
+ invalidate.dispose = () => {
65
+ value = undefined
66
+ status = 'disposed'
67
+ }
68
+ if (component[symbols.effects] !== undefined) component[symbols.effects].push(invalidate)
69
+
70
+ return () => {
71
+ if (status === 'disposed') return undefined
72
+
73
+ if (currentEffect !== null) track(component, key, true)
74
+ if (status === 'clean') return value
75
+
76
+ const previousEffect = currentEffect
77
+ const previousKey = currentKey
78
+ const previousPauseCounter = pauseCounter
79
+ const previousDependencyIndex = computedDependencyIndex
80
+ computedDependencyIndex = 0
81
+ try {
82
+ // Refresh dependencies in isolation, including reads made outside effects.
83
+ clearDependencies(invalidate)
84
+ currentEffect = invalidate
85
+ currentKey = null
86
+ pauseCounter = 0
87
+ value = getter.call(component)
88
+ status = 'clean'
89
+ return value
90
+ } catch (error) {
91
+ status = 'failed'
92
+ throw error
93
+ } finally {
94
+ const dependencies = effectDependenciesMap.get(invalidate)
95
+ if (dependencies !== undefined) dependencies.length = computedDependencyIndex
96
+ computedDependencyIndex = previousDependencyIndex
97
+ currentEffect = previousEffect
98
+ currentKey = previousKey
99
+ pauseCounter = previousPauseCounter
100
+ }
101
+ }
102
+ }
103
+
104
+ const clearDependencies = (effect, releaseStorage) => {
105
+ const dependencies = effectDependenciesMap.get(effect)
106
+ if (dependencies === undefined) return
107
+ for (let i = 0; i < dependencies.length; i++) {
108
+ const effects = dependencies[i]
109
+ effects.delete(effect)
110
+ if (effect.computed === true) effects.computedCount--
111
+ }
112
+ if (releaseStorage === true) dependencies.length = 0
113
+ }
36
114
 
37
115
  export const removeEffects = (effectsToRemove) => {
38
116
  for (let i = 0; i < effectsToRemove.length; i++) {
39
117
  const effect = effectsToRemove[i]
40
- const dependencies = effectDependenciesMap.get(effect)
41
- if (dependencies === undefined) continue
42
- for (let j = 0; j < dependencies.length; j++) {
43
- dependencies[j].delete(effect)
118
+ const computedEffect = computedEffects.get(effect)
119
+ if (computedEffect !== undefined) {
120
+ // Computed dependencies subscribe the runner itself, so unsubscribe it separately.
121
+ clearDependencies(computedEffect, true)
122
+ effectDependenciesMap.delete(computedEffect)
123
+ computedEffects.delete(effect)
44
124
  }
125
+ clearDependencies(effect, true)
45
126
  effectDependenciesMap.delete(effect)
127
+ if (effect.computed === true) effect.dispose()
46
128
  }
47
129
  }
48
130
 
49
- export const track = (target, key) => {
131
+ export const track = (target, key, computed) => {
50
132
  if (currentEffect !== null) {
51
133
  if (pauseCounter > 0) {
52
134
  return
@@ -56,6 +138,7 @@ export const track = (target, key) => {
56
138
  if (currentKey.includes(key) === false) return
57
139
  } else if (currentKey !== null && key !== currentKey) return
58
140
 
141
+ const subscriber = computed === true ? computedRunner(currentEffect, currentKey) : currentEffect
59
142
  let effectsMap = objectMap.get(target)
60
143
  if (effectsMap === undefined) {
61
144
  effectsMap = new Map()
@@ -66,14 +149,19 @@ export const track = (target, key) => {
66
149
  effects = new Set()
67
150
  effectsMap.set(key, effects)
68
151
  }
69
- if (effects.has(currentEffect) === false) {
70
- let dependencies = effectDependenciesMap.get(currentEffect)
152
+ if (effects.has(subscriber) === false) {
153
+ let dependencies = effectDependenciesMap.get(subscriber)
71
154
  if (dependencies === undefined) {
72
155
  dependencies = []
73
- effectDependenciesMap.set(currentEffect, dependencies)
156
+ effectDependenciesMap.set(subscriber, dependencies)
157
+ }
158
+ effects.add(subscriber)
159
+ if (subscriber.computed === true) {
160
+ dependencies[computedDependencyIndex++] = effects
161
+ effects.computedCount = effects.computedCount === undefined ? 1 : effects.computedCount + 1
162
+ } else {
163
+ dependencies.push(effects)
74
164
  }
75
- dependencies.push(effects)
76
- effects.add(currentEffect)
77
165
  }
78
166
  }
79
167
  }
@@ -85,14 +173,52 @@ export const trigger = (target, key, force = false) => {
85
173
  return
86
174
  }
87
175
  const effects = effectsMap.get(key)
176
+ // Ordinary state updates keep their direct path.
177
+ if (
178
+ effects !== undefined &&
179
+ pendingEffects === null &&
180
+ (effects.computedCount === undefined || effects.computedCount === 0)
181
+ ) {
182
+ for (const effect of effects) {
183
+ if (effect.computed !== true) effect(force)
184
+ }
185
+ return
186
+ }
88
187
  if (effects !== undefined) {
89
- for (let effect of effects) {
90
- effect(force)
188
+ const parentQueue = pendingEffects
189
+ // Reuse the root queue; only nested updates with pending consumers need another set.
190
+ let queue = parentQueue
191
+ if (queue === null) {
192
+ queue = effectQueue.size === 0 ? effectQueue : new Set()
193
+ // All consumers share the source mutation's force flag.
194
+ queue.force = force
195
+ }
196
+ pendingEffects = queue
197
+ try {
198
+ for (const effect of effects) {
199
+ if (effect.computed === true) {
200
+ effect(force)
201
+ } else {
202
+ const runner = computedEffects.get(effect)
203
+ queue.add(runner === undefined ? effect : runner)
204
+ }
205
+ }
206
+ // Invalidate the whole computed chain before any consumer can read its cache.
207
+ if (parentQueue === null) {
208
+ pendingEffects = null
209
+ for (const effect of queue) {
210
+ queue.delete(effect)
211
+ if (effectDependenciesMap.has(effect) === true) effect(queue.force)
212
+ }
213
+ }
214
+ } finally {
215
+ pendingEffects = parentQueue
216
+ if (parentQueue === null) queue.clear()
91
217
  }
92
218
  }
93
219
  }
94
220
 
95
- export const effect = (effectFn, key = null) => {
221
+ export const effect = (effectFn, key = null, force) => {
96
222
  const previousEffect = currentEffect
97
223
  const previousKey = currentKey
98
224
 
@@ -100,7 +226,7 @@ export const effect = (effectFn, key = null) => {
100
226
  currentKey = key
101
227
 
102
228
  try {
103
- effectFn()
229
+ effectFn(force)
104
230
  } finally {
105
231
  currentEffect = previousEffect
106
232
  currentKey = previousKey
@@ -100,9 +100,7 @@ const reactiveProxy = (original, _parent = null, _key) => {
100
100
 
101
101
  // handling objects (but not null values, which have object type in JS)
102
102
  if (typeof target[key] === 'object' && target[key] !== null) {
103
- if (Array.isArray(target[key]) === true) {
104
- track(target, key)
105
- }
103
+ track(target, key)
106
104
  // create a new reactive proxy
107
105
  return reactiveProxy(getRaw(target[key]), target, key)
108
106
  }
@@ -140,6 +138,8 @@ const reactiveProxy = (original, _parent = null, _key) => {
140
138
  Object.getPrototypeOf(value) === Object.prototype
141
139
  ) {
142
140
  value = Object.assign(receiver[key], value)
141
+ // The object keeps its identity; its nested setters already notified consumers.
142
+ isEqual = true
143
143
  }
144
144
  }
145
145
  result = Reflect.set(target, key, value, receiver)
@@ -167,7 +167,7 @@ const reactiveDefineProperty = (target) => {
167
167
 
168
168
  if (target[key] !== null && typeof target[key] === 'object') {
169
169
  if (Object.getPrototypeOf(target[key]) === Object.prototype) {
170
- return reactiveDefineProperty(target[key])
170
+ reactiveDefineProperty(target[key])
171
171
  } else if (Array.isArray(target[key]) === true) {
172
172
  for (let i = 0; i < arrayPatchMethods.length; i++) {
173
173
  target[key][arrayPatchMethods[i]] = function (v) {
@@ -189,6 +189,13 @@ const reactiveDefineProperty = (target) => {
189
189
  // todo: support assigning array (as we do with proxies)
190
190
  let oldValue = internalValue
191
191
  if (oldValue !== newValue) {
192
+ if (
193
+ newValue !== null &&
194
+ typeof newValue === 'object' &&
195
+ Object.getPrototypeOf(newValue) === Object.prototype
196
+ ) {
197
+ reactiveDefineProperty(newValue)
198
+ }
192
199
  internalValue = newValue
193
200
  trigger(target, key)
194
201
  }
@@ -44,7 +44,7 @@ import { platform } from '../platform.js'
44
44
  * @property {Object} hooks - The hooks of the route
45
45
  * @property {Object} transition - The transition of the route
46
46
  * @property {Object} announce - The announce of the route
47
- * @property {(options: Object, parentEl: BlitsElement, parentComponent: BlitsComponent, rootComponent?: BlitsComponent) => BlitsComponent} component - The component factory of the route
47
+ * @property {BlitsComponentFactory} component - The component factory of the route (or a lazy import function)
48
48
  *
49
49
  * @typedef {Object} Hash
50
50
  * @property {string} path - The path of the hash
@@ -288,9 +288,13 @@ const performNavigation = async function (viewState) {
288
288
 
289
289
  // routing to a new page (instead of routing back to a keepAlive page)
290
290
  if (view === undefined) {
291
+ // Prepare eager runtime factories before creating their holder. Lazy loaders
292
+ // retain the conservative interactive fallback until their factory is known.
293
+ if (route.component.prepare !== undefined) route.component.prepare()
294
+ const needsInteractive = route.component.needsInteractive ?? true
291
295
  // create a holder element for the new view
292
296
  holder = stage.element({ parent: this[symbols.children][0] })
293
- holder.populate({})
297
+ holder.populate({ holder: true, interactive: needsInteractive })
294
298
  holder.set('w', '100%')
295
299
  holder.set('h', '100%')
296
300
 
@@ -590,9 +594,22 @@ export const getSingleRegisteredRouterView = () => {
590
594
  return singleRouterView
591
595
  }
592
596
 
593
- export const back = function () {
597
+ export const back = function (steps = 1) {
594
598
  if (this.history === undefined) return
599
+ if (steps < 1) steps = 1
600
+ if (steps > this.history.length) steps = this.history.length
595
601
  const viewState = getViewState(this.name)
602
+
603
+ // discard the intermediate history entries when navigating back more than 1 step
604
+ for (let i = 1; i < steps; i++) {
605
+ const poppedRoute = this.history.pop()
606
+ // if skipped history item is configured as keepAlive true then invoke destroy on view
607
+ if (poppedRoute.view !== undefined && poppedRoute.view !== null) {
608
+ poppedRoute.view.destroy()
609
+ poppedRoute.focus = null
610
+ }
611
+ }
612
+
596
613
  const route = this.history.pop()
597
614
  if (route && this.currentRoute !== route) {
598
615
  // set indicator that we are navigating back (to prevent adding page to history stack)
package/src/settings.js CHANGED
@@ -19,6 +19,8 @@ import symbols from './lib/symbols.js'
19
19
 
20
20
  const settings = {
21
21
  [symbols.settings]: {},
22
+ // Reacts to Blits-only settings changed at runtime via `this.$settings.set()`.
23
+ onRuntimeSettingChange: null,
22
24
  get(key, defaultValue = null) {
23
25
  if (key in this[symbols.settings]) {
24
26
  return this[symbols.settings][key]
@@ -33,6 +35,9 @@ const settings = {
33
35
  })
34
36
  } else {
35
37
  this[symbols.settings][key] = value
38
+ if (typeof this.onRuntimeSettingChange === 'function') {
39
+ this.onRuntimeSettingChange(key, value)
40
+ }
36
41
  }
37
42
  },
38
43
  }
package/vite/index.js CHANGED
@@ -20,15 +20,18 @@ import msdfGenerator from './msdfGenerator.js'
20
20
  import blitsFileConverter from './blitsFileConverter.js'
21
21
  import reactivityGuard from './reactivityGuard.js'
22
22
  import injectDevConfig from './inject-dev-config.js'
23
+ import injectVersions from './injectVersions.js'
23
24
 
24
25
  export { default as preCompiler } from './preCompiler.js'
25
26
  export { default as msdfGenerator } from './msdfGenerator.js'
26
27
  export { default as blitsFileConverter } from './blitsFileConverter.js'
27
28
  export { default as reactivityGuard } from './reactivityGuard.js'
28
29
  export { default as injectDevConfig } from './inject-dev-config.js'
30
+ export { default as injectVersions } from './injectVersions.js'
29
31
 
30
32
  export default [
31
33
  injectDevConfig(),
34
+ injectVersions(),
32
35
  blitsFileConverter(),
33
36
  reactivityGuard(),
34
37
  preCompiler(),
@@ -0,0 +1,58 @@
1
+ /*
2
+ * Copyright 2025 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ import { readFileSync, existsSync } from 'node:fs'
19
+ import { createRequire } from 'node:module'
20
+ import { dirname, join } from 'node:path'
21
+
22
+ const require = createRequire(import.meta.url)
23
+
24
+ function getVersions() {
25
+ const blits = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
26
+ // Resolve the public entry: the renderer does not export its package.json.
27
+ // Walk upwards to support hoisted, nested, and symlinked installations.
28
+ let directory = dirname(require.resolve('@lightningjs/renderer'))
29
+ while (true) {
30
+ const filename = join(directory, 'package.json')
31
+ if (existsSync(filename)) {
32
+ const pkg = JSON.parse(readFileSync(filename, 'utf8'))
33
+ if (pkg.name === '@lightningjs/renderer') {
34
+ return { blits: blits.version, renderer: pkg.version }
35
+ }
36
+ }
37
+ const parent = dirname(directory)
38
+ if (parent === directory) throw new Error('Unable to locate @lightningjs/renderer/package.json')
39
+ directory = parent
40
+ }
41
+ }
42
+
43
+ export default function injectVersions() {
44
+ return {
45
+ name: 'inject-versions',
46
+ config(config) {
47
+ const versions = getVersions()
48
+ return {
49
+ define: {
50
+ __BLITS_VERSION__: JSON.stringify(versions.blits || 'unknown'),
51
+ __RENDERER_VERSION__: JSON.stringify(versions.renderer || 'unknown'),
52
+ // merge any existing define block
53
+ ...config.define,
54
+ },
55
+ }
56
+ },
57
+ }
58
+ }