@lightningjs/blits 2.5.0 → 2.6.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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.6.0
4
+
5
+ _25 jun 2026_
6
+
7
+ - Added platform layer for browser API abstraction (passed as a launch option)
8
+ - Improved reactivity guard vite plugin
9
+ - Added testing harness to support Blits component testing
10
+
3
11
  ## 2.5.0
4
12
 
5
13
  _11 jun 2026_
package/index.d.ts CHANGED
@@ -1016,6 +1016,74 @@ declare module '@lightningjs/blits' {
1016
1016
  type ReactivityModes = 'Proxy' | 'defineProperty'
1017
1017
  type RenderModes = 'webgl' | 'canvas'
1018
1018
 
1019
+ interface Platform {
1020
+ /**
1021
+ * Target used for keyboard and pointer input listeners.
1022
+ */
1023
+ input?: EventTarget | any,
1024
+ /**
1025
+ * Target used for viewport-level listeners such as resize, scroll, and hashchange.
1026
+ */
1027
+ viewport?: EventTarget | any,
1028
+ /**
1029
+ * Dispatch an event through the platform's event system.
1030
+ */
1031
+ dispatchEvent?: (event: any) => boolean | void,
1032
+ /**
1033
+ * Persistent key/value storage API.
1034
+ */
1035
+ localStorage?: Storage | any,
1036
+ /**
1037
+ * Read the platform cookie string.
1038
+ */
1039
+ getCookie?: () => string,
1040
+ /**
1041
+ * Write a cookie string.
1042
+ */
1043
+ setCookie?: (value: string) => void,
1044
+ /**
1045
+ * Navigate back in the platform history, when available.
1046
+ */
1047
+ historyBack?: () => void,
1048
+ /**
1049
+ * Current viewport/screen height.
1050
+ */
1051
+ screenHeight?: number,
1052
+ /**
1053
+ * Available hardware concurrency.
1054
+ */
1055
+ hardwareConcurrency?: number,
1056
+ /**
1057
+ * User agent string for platform-specific defaults.
1058
+ */
1059
+ userAgent?: string,
1060
+ /**
1061
+ * KeyboardEvent constructor used for event creation and type checks.
1062
+ */
1063
+ KeyboardEvent?: typeof KeyboardEvent | any,
1064
+ /**
1065
+ * Check whether an object should be treated as a keyboard event.
1066
+ */
1067
+ isKeyboardEvent?: (event: any) => boolean,
1068
+ /**
1069
+ * Create a keyboard event for the current platform.
1070
+ */
1071
+ createKeyboardEvent?: (type: string, init?: KeyboardEventInit | any) => any,
1072
+ /**
1073
+ * SpeechSynthesisUtterance constructor used by the announcer.
1074
+ */
1075
+ SpeechSynthesisUtterance?: typeof SpeechSynthesisUtterance | any,
1076
+ /**
1077
+ * Speech synthesis API implementation used by the announcer.
1078
+ */
1079
+ speechSynthesis?: SpeechSynthesis | any,
1080
+ /**
1081
+ * Monotonic timestamp provider used for input throttling.
1082
+ */
1083
+ now?: () => number,
1084
+ [key: string]: any,
1085
+ }
1086
+
1019
1087
  /**
1020
1088
  * Settings
1021
1089
  *
@@ -1331,11 +1399,20 @@ declare module '@lightningjs/blits' {
1331
1399
  */
1332
1400
  maxFPS?: number,
1333
1401
  /**
1334
- * Custom platform layer to be used by the App (experimental)
1402
+ * Custom platform layer to be used by the App.
1403
+ *
1404
+ * Allows replacing platform capabilities such as input targets, storage,
1405
+ * keyboard event constructors, and speech APIs.
1406
+ * A callback receives Blits' browser defaults and should return the platform overrides.
1407
+ */
1408
+ platform?: (defaults: Platform) => Partial<Platform>,
1409
+ /**
1410
+ * Custom platform layer to be used by the renderer.
1335
1411
  *
1336
- * Defaults to `null` which will be resolved internally to WebPlatform
1412
+ * For renderer-specific platform implementations. Passing a renderer platform
1413
+ * through `platform` is deprecated.
1337
1414
  */
1338
- platform?: any
1415
+ rendererPlatform?: RendererMainSettings['platform']
1339
1416
 
1340
1417
  }
1341
1418
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -32,6 +32,10 @@
32
32
  "types": "./src/plugins/storage/storage.d.ts",
33
33
  "default": "./src/plugins/storage/storage.js"
34
34
  },
35
+ "./testing": {
36
+ "types": "./src/testing/index.d.ts",
37
+ "default": "./src/testing/index.js"
38
+ },
35
39
  "./symbols": "./src/lib/symbols.js",
36
40
  "./blitsFileConverter": "./src/lib/blitsfileconverter/blitsfileconverter.js",
37
41
  "./components": "./src/components/FPScounter.js"
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { Log } from '../lib/log.js'
19
19
  import speechSynthesis from './speechSynthesis.js'
20
+ import { platform } from '../platform.js'
20
21
 
21
22
  let active = false
22
23
  let count = 0
@@ -27,7 +28,7 @@ let debounce = null
27
28
 
28
29
  // Global default utterance options
29
30
  let globalDefaultOptions = {
30
- enableUtteranceKeepAlive: !/android/i.test((self.navigator || {}).userAgent || ''),
31
+ enableUtteranceKeepAlive: !/android/i.test(platform.userAgent || ''),
31
32
  }
32
33
 
33
34
  const noopAnnouncement = {
@@ -16,8 +16,7 @@
16
16
  */
17
17
 
18
18
  import { Log } from '../lib/log.js'
19
-
20
- const syn = self.speechSynthesis
19
+ import { platform } from '../platform.js'
21
20
 
22
21
  const utterances = new Map() // id -> { utterance, timer, ignoreResume }
23
22
 
@@ -36,6 +35,7 @@ const clear = (id) => {
36
35
  }
37
36
 
38
37
  const startKeepAlive = (id) => {
38
+ const syn = platform.speechSynthesis
39
39
  const state = utterances.get(id)
40
40
 
41
41
  // utterance status: utterance was removed (cancelled or finished)
@@ -85,6 +85,7 @@ const defaultUtteranceProps = {
85
85
  }
86
86
 
87
87
  const initialize = () => {
88
+ const syn = platform.speechSynthesis
88
89
  // syn api check: syn might not have getVoices method
89
90
  if (!syn || typeof syn.getVoices !== 'function') {
90
91
  initialized = false
@@ -98,6 +99,7 @@ const initialize = () => {
98
99
 
99
100
  const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
100
101
  return new Promise((resolve) => {
102
+ const syn = platform.speechSynthesis
101
103
  if (!syn) {
102
104
  Log.debug('SpeechSynthesis - syn unavailable')
103
105
  resolve()
@@ -135,6 +137,7 @@ const waitForSynthReady = (timeoutMs = 2000, checkIntervalMs = 100) => {
135
137
  }
136
138
 
137
139
  const speak = async (options) => {
140
+ const syn = platform.speechSynthesis
138
141
  // options check: missing required options
139
142
  if (!options || !options.message) {
140
143
  return Promise.reject({ error: 'Missing message' })
@@ -154,6 +157,11 @@ const speak = async (options) => {
154
157
  // Wait for engine to be ready
155
158
  await waitForSynthReady()
156
159
 
160
+ const SpeechSynthesisUtterance = platform.SpeechSynthesisUtterance
161
+ if (SpeechSynthesisUtterance === undefined) {
162
+ return Promise.reject({ error: 'unavailable' })
163
+ }
164
+
157
165
  const utterance = new SpeechSynthesisUtterance(options.message)
158
166
  utterance.lang = options.lang || defaultUtteranceProps.lang
159
167
  utterance.pitch = options.pitch || defaultUtteranceProps.pitch
@@ -208,6 +216,7 @@ const speak = async (options) => {
208
216
 
209
217
  export default {
210
218
  speak(options) {
219
+ const syn = platform.speechSynthesis
211
220
  if (syn !== undefined) {
212
221
  if (initialized === false) {
213
222
  initialize()
@@ -219,6 +228,7 @@ export default {
219
228
  }
220
229
  },
221
230
  cancel() {
231
+ const syn = platform.speechSynthesis
222
232
  if (syn !== undefined) {
223
233
  // timers: clear all timers before cancelling
224
234
  for (const id of utterances.keys()) {
@@ -23,6 +23,7 @@ import Settings from './settings.js'
23
23
  import symbols from './lib/symbols.js'
24
24
  import { DEFAULT_HOLD_TIMEOUT_MS, DEFAULT_KEYMAP } from './constants.js'
25
25
  import { renderer } from './launch.js'
26
+ import { platform } from './platform.js'
26
27
 
27
28
  /**
28
29
  * Merged keyMap (default + custom settings).
@@ -31,6 +32,10 @@ import { renderer } from './launch.js'
31
32
  */
32
33
  export let keyMap = {}
33
34
 
35
+ export const initKeyMap = () => {
36
+ keyMap = { ...DEFAULT_KEYMAP, ...Settings.get('keymap', {}) }
37
+ }
38
+
34
39
  const Application = (config) => {
35
40
  config.hooks = config.hooks || {}
36
41
 
@@ -39,22 +44,23 @@ const Application = (config) => {
39
44
  let mouseMoveHandler
40
45
  let mouseClickHandler
41
46
  let updateCanvasRect
47
+ let inputCleanup
48
+ let mouseCleanup
42
49
  let holdTimeout
43
50
  let lastInputTime = 0
44
51
  let lastInputKey = null
45
- let mouseListenersAdded = false
46
52
 
47
53
  config.hooks[symbols.destroy] = function () {
48
54
  // Cancel pending key-hold timeout and reset hold state so focus is not left in hold mode after teardown
49
55
  clearTimeout(holdTimeout)
50
56
  Focus.hold = false
51
- document.removeEventListener('keydown', keyDownHandler)
52
- document.removeEventListener('keyup', keyUpHandler)
53
- if (mouseListenersAdded) {
54
- document.removeEventListener('mousemove', mouseMoveHandler)
55
- document.removeEventListener('click', mouseClickHandler)
56
- window.removeEventListener('resize', updateCanvasRect)
57
- window.removeEventListener('scroll', updateCanvasRect)
57
+ if (typeof inputCleanup === 'function') {
58
+ inputCleanup()
59
+ inputCleanup = undefined
60
+ }
61
+ if (typeof mouseCleanup === 'function') {
62
+ mouseCleanup()
63
+ mouseCleanup = undefined
58
64
  Hover.clear()
59
65
  }
60
66
  }
@@ -68,15 +74,16 @@ const Application = (config) => {
68
74
  this.$announcer.configure(announcerOptions)
69
75
  }
70
76
  // Initialize merged keyMap once during application init
71
- keyMap = { ...DEFAULT_KEYMAP, ...Settings.get('keymap', {}) }
77
+ initKeyMap()
72
78
 
73
79
  /** @type {number} Input throttle time in milliseconds (0 = disabled) */
74
80
  const throttleMs = Settings.get('inputThrottle', 0)
75
81
 
76
82
  const mouseEnabled = Settings.get('enableMouse', false)
83
+ const { createKeyboardEvent, input, isKeyboardEvent, now = Date.now, viewport } = platform
77
84
 
78
85
  keyDownHandler = async (e) => {
79
- const currentTime = performance.now()
86
+ const currentTime = now()
80
87
 
81
88
  const key = keyMap[e.keyCode] || e.keyCode
82
89
  const sameKey = lastInputKey === key
@@ -103,7 +110,7 @@ const Application = (config) => {
103
110
  ) {
104
111
  e = await this[symbols.inputEvents].intercept.call(this, e)
105
112
  // only pass on the key press to focused component when keyboard event is returned
106
- if (e instanceof KeyboardEvent === false) return
113
+ if (isKeyboardEvent && isKeyboardEvent(e) === false) return
107
114
  }
108
115
 
109
116
  Focus.input(key, e)
@@ -183,7 +190,9 @@ const Application = (config) => {
183
190
  mouseClickHandler = () => {
184
191
  if (currentComponent === undefined || currentComponent.eol === true) return
185
192
 
186
- const e = new KeyboardEvent('keydown', {
193
+ if (createKeyboardEvent === undefined) return
194
+
195
+ const e = createKeyboardEvent('keydown', {
187
196
  key: 'Enter',
188
197
  code: 'Enter',
189
198
  keyCode: 13,
@@ -195,15 +204,26 @@ const Application = (config) => {
195
204
  currentComponent.$input(e)
196
205
  }
197
206
 
198
- document.addEventListener('keydown', keyDownHandler)
199
- document.addEventListener('keyup', keyUpHandler)
200
- if (mouseEnabled === true) {
207
+ if (input !== undefined) {
208
+ input.addEventListener('keydown', keyDownHandler)
209
+ input.addEventListener('keyup', keyUpHandler)
210
+ inputCleanup = () => {
211
+ input.removeEventListener('keydown', keyDownHandler)
212
+ input.removeEventListener('keyup', keyUpHandler)
213
+ }
214
+ }
215
+ if (mouseEnabled === true && input !== undefined && viewport !== undefined) {
201
216
  updateCanvasRect()
202
- document.addEventListener('mousemove', mouseMoveHandler)
203
- document.addEventListener('click', mouseClickHandler)
204
- window.addEventListener('resize', updateCanvasRect)
205
- window.addEventListener('scroll', updateCanvasRect)
206
- mouseListenersAdded = true
217
+ input.addEventListener('mousemove', mouseMoveHandler)
218
+ input.addEventListener('click', mouseClickHandler)
219
+ viewport.addEventListener('resize', updateCanvasRect)
220
+ viewport.addEventListener('scroll', updateCanvasRect)
221
+ mouseCleanup = () => {
222
+ input.removeEventListener('mousemove', mouseMoveHandler)
223
+ input.removeEventListener('click', mouseClickHandler)
224
+ viewport.removeEventListener('resize', updateCanvasRect)
225
+ viewport.removeEventListener('scroll', updateCanvasRect)
226
+ }
207
227
  }
208
228
 
209
229
  // next tick
@@ -23,6 +23,7 @@ import { Log } from '../../lib/log.js'
23
23
  import { removeGlobalEffects } from '../../lib/reactivity/effect.js'
24
24
  import { renderer } from '../../launch.js'
25
25
  import { keyMap } from '../../application.js'
26
+ import { platform } from '../../platform.js'
26
27
 
27
28
  export default {
28
29
  $focus: {
@@ -48,7 +49,11 @@ export default {
48
49
  * @returns {boolean} - Returns true if this component or a parent component handled the event, false otherwise
49
50
  */
50
51
  value: function (event) {
51
- if (event === null || event === undefined || event instanceof KeyboardEvent === false)
52
+ if (
53
+ event === null ||
54
+ event === undefined ||
55
+ (platform.isKeyboardEvent && platform.isKeyboardEvent(event) === false)
56
+ )
52
57
  return false
53
58
 
54
59
  const key = keyMap[event.keyCode] || event.keyCode
@@ -23,6 +23,7 @@ import Router, {
23
23
  } from '../router/router.js'
24
24
  import symbols from '../lib/symbols.js'
25
25
  import Focus from '../focus/focus.js'
26
+ import { platform } from '../platform.js'
26
27
 
27
28
  /** @typedef {{ $input?: (event: any) => boolean, $focus?: (event: any) => void }} RouterViewParent */
28
29
 
@@ -159,11 +160,18 @@ export default () =>
159
160
  Router.navigate.apply(this)
160
161
  }
161
162
 
162
- window.addEventListener('hashchange', this.hashchangeHandler)
163
+ const viewport = platform.viewport
164
+ if (viewport !== undefined) {
165
+ this.hashchangeViewport = viewport
166
+ viewport.addEventListener('hashchange', this.hashchangeHandler)
167
+ }
163
168
  },
164
169
  destroy() {
165
170
  unregisterRouterView(this)
166
- window.removeEventListener('hashchange', this.hashchangeHandler, false)
171
+ if (this.hashchangeViewport !== undefined) {
172
+ this.hashchangeViewport.removeEventListener('hashchange', this.hashchangeHandler, false)
173
+ this.hashchangeViewport = undefined
174
+ }
167
175
  },
168
176
  focus() {
169
177
  if (this.activeView && Focus.get() === this) {
@@ -25,6 +25,7 @@ import { SCREEN_RESOLUTIONS, RENDER_QUALITIES } from '../../constants.js'
25
25
  import colors from '../../lib/colors/colors.js'
26
26
  import fontLoader from './fontLoader.js'
27
27
  import shaderLoader from './shaderLoader.js'
28
+ import { platform } from '../../platform.js'
28
29
 
29
30
  /** @type {RendererMain|{}} */
30
31
  export let renderer = {}
@@ -73,6 +74,11 @@ const textureMemorySettings = (settings) => {
73
74
  *
74
75
  */
75
76
  export default (App, target, settings = {}) => {
77
+ const screenHeight = settings.platform ? settings.platform.screenHeight : platform.screenHeight
78
+ const hardwareConcurrency = settings.platform
79
+ ? settings.platform.hardwareConcurrency
80
+ : platform.hardwareConcurrency
81
+
76
82
  renderer = new RendererMain(
77
83
  {
78
84
  ...{
@@ -84,12 +90,10 @@ export default (App, target, settings = {}) => {
84
90
  deviceLogicalPixelRatio:
85
91
  settings.pixelRatio ||
86
92
  SCREEN_RESOLUTIONS[settings.screenResolution] ||
87
- SCREEN_RESOLUTIONS[window.innerHeight] ||
93
+ SCREEN_RESOLUTIONS[screenHeight] ||
88
94
  1,
89
95
  numImageWorkers:
90
- 'webWorkersLimit' in settings
91
- ? settings.webWorkersLimit
92
- : window.navigator.hardwareConcurrency || 2,
96
+ 'webWorkersLimit' in settings ? settings.webWorkersLimit : hardwareConcurrency || 2,
93
97
  clearColor: (settings.canvasColor && colors.normalize(settings.canvasColor)) || 0x00000000,
94
98
  inspector: settings.inspector === true ? Inspector : undefined,
95
99
  boundsMargin: settings.viewportMargin || 0,
@@ -100,7 +104,7 @@ export default (App, target, settings = {}) => {
100
104
  textureMemory: textureMemorySettings(settings),
101
105
  createImageBitmapSupport: 'auto',
102
106
  targetFPS: 'maxFPS' in settings ? settings.maxFPS : 0,
103
- platform: 'platform' in settings ? settings.platform : null,
107
+ platform: settings.rendererPlatform || null,
104
108
  },
105
109
  ...(settings.advanced || {}),
106
110
  },
@@ -21,7 +21,7 @@ import Settings from '../settings.js'
21
21
  import { DEFAULT_HOLD_TIMEOUT_MS } from '../constants.js'
22
22
  import { Log } from '../lib/log.js'
23
23
  import { getAncestors } from './helpers.js'
24
- import InternalKeyboardEvent from '../lib/events/internalkeyboardevent.js'
24
+ import { platform } from '../platform.js'
25
25
 
26
26
  let focusedComponent = null
27
27
  let focusChain = []
@@ -136,8 +136,11 @@ const setFocus = (component, event) => {
136
136
  focusedComponent = component
137
137
  component[symbols.lifecycle].state = 'focus'
138
138
 
139
- if (event instanceof KeyboardEvent) {
140
- const internalEvent = new InternalKeyboardEvent('keydown', event)
141
- document.dispatchEvent(internalEvent)
139
+ if (platform.isKeyboardEvent && platform.isKeyboardEvent(event) && platform.dispatchEvent) {
140
+ const internalEvent = platform.createKeyboardEvent
141
+ ? platform.createKeyboardEvent('keydown', event)
142
+ : event
143
+ internalEvent[symbols.internalEvent] = true
144
+ platform.dispatchEvent(internalEvent)
142
145
  }
143
146
  }
package/src/launch.js CHANGED
@@ -19,6 +19,7 @@ import Settings from './settings.js'
19
19
  import { initLog, Log } from './lib/log.js'
20
20
  import engine from './engine.js'
21
21
  import blitsPackageInfo from '../package.json' with { type: 'json' }
22
+ import { configurePlatform } from './platform.js'
22
23
 
23
24
  /**
24
25
  * @typedef {Object} Font
@@ -86,10 +87,12 @@ async function rendererVersion() {
86
87
  * @property {number} [holdTimeout] - Time after which a key press is considered a hold
87
88
  * @property {HTMLCanvasElement} [canvas] - Custom canvas object used to render the App
88
89
  * @property {number} [textureProcessingTimeLimit] - Max time renderer can process textures in a frame
90
+ * @property {import('@lightningjs/renderer').RendererMainSettings['platform']} [rendererPlatform] - Custom platform used by the renderer
89
91
  * @property {Partial<import('@lightningjs/renderer').RendererMainSettings>} [advanced] - Advanced renderer settings
90
92
  * @property {boolean} [announcer] - Whether or not the announcer should be activated on initialization
91
93
  * @property {boolean} [enableMouse] - Enable mouse support (hover and click-to-focus). Defaults to false.
92
94
  * @property {number} [maxFPS] - Maximum FPS
95
+ * @property {function} [platform] - Platform factory for non-browser environments
93
96
  */
94
97
 
95
98
  /**
@@ -109,10 +112,30 @@ async function rendererVersion() {
109
112
  * @returns {void}
110
113
  */
111
114
  export default (App, target, settings) => {
115
+ let warnLegacyRendererPlatform = false
116
+
117
+ if (settings !== undefined && 'platform' in settings) {
118
+ if (typeof settings.platform === 'function') {
119
+ settings.platform = configurePlatform(settings.platform)
120
+ } else {
121
+ if ('rendererPlatform' in settings === false) {
122
+ settings.rendererPlatform = settings.platform
123
+ }
124
+ delete settings.platform
125
+ warnLegacyRendererPlatform = true
126
+ }
127
+ }
128
+
112
129
  Settings.set(settings)
113
130
 
114
131
  initLog()
115
132
 
133
+ if (warnLegacyRendererPlatform === true) {
134
+ Log.warn(
135
+ 'Passing a renderer platform via settings.platform is deprecated. Use settings.rendererPlatform instead.'
136
+ )
137
+ }
138
+
116
139
  rendererVersion().then((v) => {
117
140
  Log.info('Blits Version ', blitsPackageInfo.version)
118
141
  Log.info('Renderer Version ', v)
@@ -200,8 +200,12 @@ const processComputedBlock = (configObject) => {
200
200
  currentPos = skipNonCode(computedObj, currentPos)
201
201
  if (currentPos >= computedObj.length - 1) break
202
202
 
203
- // Find the next function name (computed properties are `name() { ... }`)
204
- const functionNameMatch = /^(\w+)\s*\(\)\s*/.exec(computedObj.substring(currentPos))
203
+ // Find the next function name (computed properties are `name() { ... }`
204
+ // or long-form `name: function() { ... }`)
205
+ const functionNameMatch = /^(\w+)\s*(?:\(\)|:\s*function\s*\([^)]*\))\s*/.exec(
206
+ computedObj.substring(currentPos)
207
+ )
208
+
205
209
  if (!functionNameMatch) {
206
210
  const nextPos = skipComputedMember(computedObj, currentPos)
207
211
  if (nextPos === -1) break
@@ -0,0 +1,117 @@
1
+ /*
2
+ * Copyright 2026 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
+ const globalScope = globalThis
19
+
20
+ const createKeyboardEventFactory = (KeyboardEventConstructor) => {
21
+ if (KeyboardEventConstructor === undefined) return undefined
22
+
23
+ return (type, init = {}) => {
24
+ const event = new KeyboardEventConstructor(type, init)
25
+ if ('keyCode' in init && event.keyCode !== init.keyCode) {
26
+ Object.defineProperty(event, 'keyCode', {
27
+ get: () => init.keyCode,
28
+ })
29
+ }
30
+ return event
31
+ }
32
+ }
33
+
34
+ const browserPlatform = () => {
35
+ const windowRef = globalScope.window
36
+ const documentRef = globalScope.document || (windowRef && windowRef.document)
37
+ const selfRef = globalScope.self || windowRef || globalScope
38
+ const navigatorRef =
39
+ globalScope.navigator || (windowRef && windowRef.navigator) || (selfRef && selfRef.navigator)
40
+ const KeyboardEventConstructor =
41
+ globalScope.KeyboardEvent || (windowRef && windowRef.KeyboardEvent)
42
+ let localStorage
43
+
44
+ try {
45
+ localStorage = (windowRef && windowRef.localStorage) || globalScope.localStorage
46
+ } catch {
47
+ localStorage = undefined
48
+ }
49
+
50
+ const createKeyboardEvent = createKeyboardEventFactory(KeyboardEventConstructor)
51
+
52
+ return {
53
+ input: documentRef,
54
+ viewport: windowRef,
55
+ dispatchEvent:
56
+ documentRef && documentRef.dispatchEvent
57
+ ? documentRef.dispatchEvent.bind(documentRef)
58
+ : undefined,
59
+ localStorage,
60
+ getCookie: () => (documentRef && documentRef.cookie) || '',
61
+ setCookie: (value) => {
62
+ if (documentRef !== undefined) documentRef.cookie = value
63
+ },
64
+ historyBack: () => {
65
+ if (windowRef && windowRef.history && windowRef.history.back) windowRef.history.back()
66
+ },
67
+ screenHeight: windowRef && windowRef.innerHeight,
68
+ hardwareConcurrency: navigatorRef && navigatorRef.hardwareConcurrency,
69
+ userAgent: navigatorRef && navigatorRef.userAgent,
70
+ KeyboardEvent: KeyboardEventConstructor,
71
+ isKeyboardEvent:
72
+ KeyboardEventConstructor !== undefined
73
+ ? (event) => event instanceof KeyboardEventConstructor
74
+ : undefined,
75
+ createKeyboardEvent,
76
+ SpeechSynthesisUtterance:
77
+ globalScope.SpeechSynthesisUtterance || (windowRef && windowRef.SpeechSynthesisUtterance),
78
+ speechSynthesis:
79
+ (selfRef && selfRef.speechSynthesis) || (windowRef && windowRef.speechSynthesis),
80
+ now:
81
+ globalScope.performance && globalScope.performance.now !== undefined
82
+ ? globalScope.performance.now.bind(globalScope.performance)
83
+ : Date.now,
84
+ }
85
+ }
86
+
87
+ export let platform = browserPlatform()
88
+
89
+ export const configurePlatform = (customPlatform) => {
90
+ const basePlatform = browserPlatform()
91
+ const resolvedCustomPlatform = customPlatform(basePlatform) || {}
92
+
93
+ platform = {
94
+ ...basePlatform,
95
+ ...resolvedCustomPlatform,
96
+ }
97
+
98
+ platform.KeyboardEvent = platform.KeyboardEvent || basePlatform.KeyboardEvent
99
+ platform.isKeyboardEvent =
100
+ resolvedCustomPlatform.isKeyboardEvent ||
101
+ (platform.KeyboardEvent !== undefined
102
+ ? (event) => event instanceof platform.KeyboardEvent
103
+ : basePlatform.isKeyboardEvent)
104
+ platform.createKeyboardEvent =
105
+ resolvedCustomPlatform.createKeyboardEvent ||
106
+ createKeyboardEventFactory(platform.KeyboardEvent) ||
107
+ basePlatform.createKeyboardEvent
108
+ platform.getCookie = platform.getCookie || basePlatform.getCookie
109
+ platform.setCookie = platform.setCookie || basePlatform.setCookie
110
+ platform.historyBack = platform.historyBack || basePlatform.historyBack
111
+ platform.SpeechSynthesisUtterance =
112
+ platform.SpeechSynthesisUtterance || basePlatform.SpeechSynthesisUtterance
113
+ platform.speechSynthesis = platform.speechSynthesis || basePlatform.speechSynthesis
114
+ platform.now = platform.now || basePlatform.now
115
+
116
+ return platform
117
+ }
@@ -15,6 +15,8 @@
15
15
  * SPDX-License-Identifier: Apache-2.0
16
16
  */
17
17
 
18
+ import { platform } from '../../platform.js'
19
+
18
20
  // LocalStorage and Cookie storage library
19
21
  class localCookie {
20
22
  constructor(options) {
@@ -41,7 +43,8 @@ class localCookie {
41
43
 
42
44
  // Checks if local storage is available and works
43
45
  _checkIfLocalStorageWorks() {
44
- if (typeof localStorage !== 'undefined') {
46
+ const localStorage = platform.localStorage
47
+ if (localStorage !== undefined) {
45
48
  try {
46
49
  localStorage.setItem('feature_test', 'yes')
47
50
  if (localStorage.getItem('feature_test') === 'yes') {
@@ -66,23 +69,23 @@ class localCookie {
66
69
  * Local Storage APIs
67
70
  */
68
71
  _getItemLocalStorage(key) {
69
- return window.localStorage.getItem(key)
72
+ return platform.localStorage.getItem(key)
70
73
  }
71
74
 
72
75
  _setItemLocalStorage(key, value) {
73
- return window.localStorage.setItem(key, value)
76
+ return platform.localStorage.setItem(key, value)
74
77
  }
75
78
 
76
79
  _removeItemLocalStorage(key) {
77
- return window.localStorage.removeItem(key)
80
+ return platform.localStorage.removeItem(key)
78
81
  }
79
82
 
80
83
  _clearLocalStorage() {
81
- return window.localStorage.clear()
84
+ return platform.localStorage.clear()
82
85
  }
83
86
 
84
87
  _getLocalStorageKeys() {
85
- return Object.keys(window.localStorage)
88
+ return Object.keys(platform.localStorage)
86
89
  }
87
90
 
88
91
  /*
@@ -91,7 +94,7 @@ class localCookie {
91
94
  _getItemCookie(key) {
92
95
  /* eslint-disable */
93
96
  function escape(s) { return s.replace(/([.*+?\^${}()|\[\]\/\\])/g, '\\$1'); };
94
- var match = document.cookie.match(RegExp('(?:^|;\\s*)' + escape(key) + '=([^;]*)'));
97
+ var match = platform.getCookie().match(RegExp('(?:^|;\\s*)' + escape(key) + '=([^;]*)'));
95
98
 
96
99
  //if value in key of the entire cookie is empty, its deleted
97
100
  if (match && match[1] === '')
@@ -103,13 +106,13 @@ class localCookie {
103
106
  _setItemCookie(key, value) {
104
107
  var now = new Date()
105
108
  var expires = new Date(now.getTime() +1000*60*60*24*365*5) // 5 years!
106
- document.cookie = `${key}=${value}; expires=${expires.toUTCString()};`
109
+ platform.setCookie(`${key}=${value}; expires=${expires.toUTCString()};`)
107
110
  return //return undefined just like localStorage
108
111
  }
109
112
 
110
113
  _removeItemCookie(key) {
111
114
  // to delete an k/v set the key to empty
112
- document.cookie = `${key}=;Max-Age=-99999999;`
115
+ platform.setCookie(`${key}=;Max-Age=-99999999;`)
113
116
  return //return undefined just like localStorage
114
117
  }
115
118
 
@@ -117,14 +120,14 @@ class localCookie {
117
120
  // empty all key/values in cookies
118
121
  // What about cookies we got from the server? ¯\_(ツ)_/¯
119
122
  // They should have the HttpOnly flag, so we shouldn't be able to delete them
120
- document.cookie.split(";").forEach( (c) => {
121
- document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=;Max-Age=-99999999");
123
+ platform.getCookie().split(";").forEach( (c) => {
124
+ platform.setCookie(c.replace(/^ +/, "").replace(/=.*/, "=;expires=;Max-Age=-99999999"));
122
125
  });
123
126
 
124
127
  return //return undefined
125
128
  }
126
129
  _getCookieKeys() {
127
- return document.cookie.split(";").map((item)=>item.trim().split("=")[0])
130
+ return platform.getCookie().split(";").map((item)=>item.trim().split("=")[0])
128
131
  }
129
132
  }
130
133
 
@@ -24,6 +24,7 @@ import { stage } from '../launch.js'
24
24
  import Focus from '../focus/focus.js'
25
25
  import Announcer from '../announcer/announcer.js'
26
26
  import Settings from '../settings.js'
27
+ import { platform } from '../platform.js'
27
28
 
28
29
  /**
29
30
  * @typedef {import('../component.js').BlitsComponentFactory} BlitsComponentFactory - The component of the route
@@ -417,7 +418,8 @@ const executeBeforeHook = async function (
417
418
  this.currentRoute = previousRoute
418
419
  if (this.history.length > 0) {
419
420
  preventHashChangeNavigation = true
420
- window.history.back()
421
+ platform.historyBack()
422
+
421
423
  navigatingBack = false
422
424
  state.navigating = false
423
425
  }
@@ -434,7 +436,7 @@ const executeBeforeHook = async function (
434
436
  this.currentRoute = previousRoute
435
437
  if (this.history.length > 0) {
436
438
  preventHashChangeNavigation = true
437
- window.history.back()
439
+ platform.historyBack()
438
440
  navigatingBack = false
439
441
  state.navigating = false
440
442
  }
@@ -0,0 +1,58 @@
1
+ /*
2
+ * Copyright 2026 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 type { ComponentFactory, Settings } from '@lightningjs/blits'
19
+
20
+ export interface SnapshotNode {
21
+ type: string
22
+ attributes: Record<string, any>
23
+ children: Array<SnapshotNode | ComponentSnapshotNode>
24
+ }
25
+
26
+ export interface ComponentSnapshotNode {
27
+ type: 'Component'
28
+ name: string
29
+ hasFocus: boolean
30
+ isHovered: boolean
31
+ attributes: Record<string, any>
32
+ props: Record<string, any>
33
+ state: Record<string, any>
34
+ tree: SnapshotNode | null
35
+ }
36
+
37
+ export interface RenderComponentOptions {
38
+ props?: Record<string, any>
39
+ settings?: Settings
40
+ }
41
+
42
+ export interface RenderComponentFixture {
43
+ component: any
44
+ root: any
45
+ snapshot(): SnapshotNode | ComponentSnapshotNode
46
+ setProps(props: Record<string, any>): void
47
+ setState(state: Record<string, any>): void
48
+ focus(event?: KeyboardEvent): Promise<any>
49
+ unfocus(): any
50
+ createKeyboardEvent(key: string, init?: KeyboardEventInit): KeyboardEvent
51
+ input(key: string, event?: KeyboardEvent): boolean
52
+ destroy(): void
53
+ }
54
+
55
+ export function renderComponent(
56
+ Component: ComponentFactory,
57
+ options?: RenderComponentOptions
58
+ ): RenderComponentFixture
@@ -0,0 +1,20 @@
1
+ /*
2
+ * Copyright 2026 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 renderComponent from './renderComponent.js'
19
+
20
+ export { renderComponent }
@@ -0,0 +1,116 @@
1
+ /*
2
+ * Copyright 2026 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
+ class TestKeyboardEvent {
19
+ constructor(type, init = {}) {
20
+ this.type = type
21
+ this.key = init.key
22
+ this.code = init.code
23
+ this.keyCode = init.keyCode || 0
24
+ this.bubbles = init.bubbles === true
25
+ this.cancelable = init.cancelable === true
26
+ this.composed = init.composed === true
27
+ this.timeStamp = init.timeStamp || Date.now()
28
+ }
29
+ }
30
+
31
+ const createEventTarget = () => {
32
+ const listeners = {}
33
+
34
+ return {
35
+ addEventListener(type, handler) {
36
+ listeners[type] = listeners[type] || []
37
+ listeners[type].push(handler)
38
+ },
39
+ removeEventListener(type, handler) {
40
+ const handlers = listeners[type]
41
+ if (handlers === undefined) return
42
+ const index = handlers.indexOf(handler)
43
+ if (index > -1) handlers.splice(index, 1)
44
+ },
45
+ dispatchEvent(event) {
46
+ const handlers = listeners[event.type] || []
47
+ for (let i = 0; i < handlers.length; i++) {
48
+ handlers[i](event)
49
+ }
50
+ return true
51
+ },
52
+ }
53
+ }
54
+
55
+ const createStorage = () => {
56
+ const store = {}
57
+
58
+ return {
59
+ getItem(key) {
60
+ return store[key] !== undefined ? store[key] : null
61
+ },
62
+ setItem(key, value) {
63
+ store[key] = String(value)
64
+ },
65
+ removeItem(key) {
66
+ delete store[key]
67
+ },
68
+ clear() {
69
+ const keys = Object.keys(store)
70
+ for (let i = 0; i < keys.length; i++) {
71
+ delete store[keys[i]]
72
+ }
73
+ },
74
+ }
75
+ }
76
+
77
+ const nodePlatform = () => {
78
+ const input = createEventTarget()
79
+ const viewport = createEventTarget()
80
+ let cookie = ''
81
+
82
+ return {
83
+ input,
84
+ viewport,
85
+ dispatchEvent: input.dispatchEvent.bind(input),
86
+ localStorage: createStorage(),
87
+ getCookie: () => cookie,
88
+ setCookie: (value) => {
89
+ cookie = value
90
+ },
91
+ historyBack() {},
92
+ screenHeight: 1080,
93
+ hardwareConcurrency: 1,
94
+ userAgent: 'node',
95
+ KeyboardEvent: TestKeyboardEvent,
96
+ isKeyboardEvent: (event) => event instanceof TestKeyboardEvent,
97
+ createKeyboardEvent: (type, init = {}) => new TestKeyboardEvent(type, init),
98
+ SpeechSynthesisUtterance: class TestSpeechSynthesisUtterance {
99
+ constructor(text = '') {
100
+ this.text = text
101
+ }
102
+ },
103
+ speechSynthesis: {
104
+ cancel() {},
105
+ getVoices() {
106
+ return []
107
+ },
108
+ pause() {},
109
+ resume() {},
110
+ speak() {},
111
+ },
112
+ now: Date.now,
113
+ }
114
+ }
115
+
116
+ export default nodePlatform
@@ -0,0 +1,346 @@
1
+ /*
2
+ * Copyright 2026 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 Settings from '../settings.js'
19
+ import { renderer, stage } from '../launch.js'
20
+ import symbols from '../lib/symbols.js'
21
+ import { initLog } from '../lib/log.js'
22
+ import { getRaw } from '../lib/reactivity/reactive.js'
23
+ import Focus from '../focus/focus.js'
24
+ import { initKeyMap, keyMap } from '../application.js'
25
+ import { configurePlatform, platform } from '../platform.js'
26
+ import nodePlatform from './nodePlatform.js'
27
+
28
+ let nodeId = 0
29
+
30
+ const renderComponent = (Component, options = {}) => {
31
+ const originalElement = stage.element
32
+ const originalPlatform = platform
33
+
34
+ Settings.set(options.settings || {})
35
+ configurePlatform(() => nodePlatform())
36
+ initLog()
37
+ initKeyMap()
38
+ mockRenderer()
39
+ // Generated template code calls stage.element through `this.element`
40
+ stage.element = createElement
41
+
42
+ const root = createElement()
43
+ const parent = {
44
+ $componentId: 'BlitsTestComponent::Root',
45
+ [symbols.lifecycle]: {
46
+ set state(v) {},
47
+ },
48
+ $focus() {},
49
+ $input() {
50
+ return false
51
+ },
52
+ }
53
+ const component = Component({ props: options.props || {} }, root, parent)
54
+
55
+ const snapshot = () => {
56
+ const holderMap = new WeakMap()
57
+ collectComponents(component, holderMap)
58
+ return componentSnapshot(component, holderMap)
59
+ }
60
+
61
+ const setProps = (props = {}) => {
62
+ const keys = Object.keys(props)
63
+ for (let i = 0; i < keys.length; i++) {
64
+ component[symbols.props][keys[i]] = props[keys[i]]
65
+ }
66
+ }
67
+
68
+ const setState = (state = {}) => {
69
+ const keys = Object.keys(state)
70
+ for (let i = 0; i < keys.length; i++) {
71
+ component[symbols.state][keys[i]] = state[keys[i]]
72
+ }
73
+ }
74
+
75
+ const focus = (event) => {
76
+ Focus.hold = false
77
+ component.$focus(event)
78
+ return new Promise((resolve) => {
79
+ setTimeout(() => {
80
+ setTimeout(() => resolve(component))
81
+ })
82
+ })
83
+ }
84
+
85
+ const unfocus = () => {
86
+ component.unfocus()
87
+ return component
88
+ }
89
+
90
+ const input = (key, event = createKeyboardEvent(key)) => {
91
+ const focused = Focus.get()
92
+ if (focused === null || focused.eol === true || isInComponentTree(focused, component) === false)
93
+ return false
94
+ Focus.input(key, event)
95
+ return true
96
+ }
97
+
98
+ const destroy = () => {
99
+ if (component.eol !== true) {
100
+ component.destroy()
101
+ }
102
+ stage.element = originalElement
103
+ configurePlatform(() => originalPlatform)
104
+ }
105
+
106
+ return {
107
+ component,
108
+ root,
109
+ snapshot,
110
+ setProps,
111
+ setState,
112
+ focus,
113
+ unfocus,
114
+ createKeyboardEvent,
115
+ input,
116
+ destroy,
117
+ }
118
+ }
119
+
120
+ const mockRenderer = () => {
121
+ // Lifecycle hooks register renderer events even in headless tests
122
+ if (renderer.on === undefined) renderer.on = () => {}
123
+ if (renderer.off === undefined) renderer.off = () => {}
124
+ }
125
+
126
+ const collectComponents = (component, holderMap) => {
127
+ if (component === undefined || component === null) return
128
+ // Component tags render as holder elements; keep the link for snapshots
129
+ if (component[symbols.holder] !== undefined) {
130
+ holderMap.set(component[symbols.holder], component)
131
+ }
132
+ const children = component[symbols.children] || []
133
+ for (let i = 0; i < children.length; i++) {
134
+ const child = children[i]
135
+ if (child && child.$componentId !== undefined) {
136
+ collectComponents(child, holderMap)
137
+ }
138
+ }
139
+ }
140
+
141
+ const elementSnapshot = (element, holderMap) => {
142
+ const component = holderMap.get(element)
143
+ if (component !== undefined) {
144
+ return componentSnapshot(component, holderMap)
145
+ }
146
+
147
+ const children = []
148
+ for (let i = 0; i < element.children.length; i++) {
149
+ children.push(elementSnapshot(element.children[i], holderMap))
150
+ }
151
+
152
+ return {
153
+ type: elementType(element.attributes),
154
+ attributes: attributesSnapshot(element.attributes),
155
+ children,
156
+ }
157
+ }
158
+
159
+ const componentSnapshot = (component, holderMap) => {
160
+ const holder = component[symbols.holder]
161
+ const wrapper = component[symbols.wrapper]
162
+
163
+ return {
164
+ type: 'Component',
165
+ name: componentName(component),
166
+ hasFocus: component.$hasFocus === true,
167
+ isHovered: component.$isHovered === true,
168
+ attributes: holder ? attributesSnapshot(holder.attributes) : {},
169
+ props: propsSnapshot(component),
170
+ state: stateSnapshot(component),
171
+ tree: wrapper ? elementSnapshot(wrapper, holderMap) : null,
172
+ }
173
+ }
174
+
175
+ const attributesSnapshot = (attributes) => {
176
+ const out = {}
177
+ const keys = Object.keys(attributes)
178
+ for (let i = 0; i < keys.length; i++) {
179
+ const key = keys[i]
180
+ if (
181
+ key === '___wrapper' ||
182
+ key === '__textnode' ||
183
+ key === '__layout' ||
184
+ key === 'holder' ||
185
+ key === 'parent'
186
+ ) {
187
+ continue
188
+ }
189
+ if (attributes[key] !== undefined) {
190
+ out[key] = clone(attributes[key])
191
+ }
192
+ }
193
+ return out
194
+ }
195
+
196
+ const propsSnapshot = (component) => {
197
+ const props = component[symbols.props] || {}
198
+ const propKeys = component[symbols.propKeys] || Object.keys(props)
199
+ const out = {}
200
+ for (let i = 0; i < propKeys.length; i++) {
201
+ const key = propKeys[i]
202
+ if (props[key] !== undefined) {
203
+ out[key] = clone(props[key])
204
+ }
205
+ }
206
+ return out
207
+ }
208
+
209
+ const stateSnapshot = (component) => {
210
+ const state = component[symbols.state] || {}
211
+ const originalState = component[symbols.originalState] || {}
212
+ const stateKeys = Object.keys(originalState)
213
+ const out = {}
214
+ for (let i = 0; i < stateKeys.length; i++) {
215
+ const key = stateKeys[i]
216
+ if (key === '$hasFocus' || key === '$isHovered') continue
217
+ if (state[key] !== undefined) {
218
+ out[key] = clone(state[key])
219
+ }
220
+ }
221
+ return out
222
+ }
223
+
224
+ const componentName = (component) => {
225
+ const match = component.$componentId && component.$componentId.match(/^BlitsComponent::(.+)_\d+$/)
226
+ return match ? match[1] : 'Component'
227
+ }
228
+
229
+ const elementType = (attributes) => {
230
+ if (attributes.__textnode === true) return 'Text'
231
+ if (attributes.__layout === true) return 'Layout'
232
+ if (attributes[symbols.isSlot] === true) return 'Slot'
233
+ if (attributes[symbols.isSprite] === true) return 'Sprite'
234
+ return 'Element'
235
+ }
236
+
237
+ const clone = (value) => {
238
+ value = getRaw(value)
239
+
240
+ if (Array.isArray(value) === true) {
241
+ return value.map((v) => clone(v))
242
+ }
243
+
244
+ if (
245
+ value !== null &&
246
+ typeof value === 'object' &&
247
+ Object.getPrototypeOf(value) === Object.prototype
248
+ ) {
249
+ return cleanObject(value)
250
+ }
251
+
252
+ return value
253
+ }
254
+
255
+ const cleanObject = (value) => {
256
+ const out = {}
257
+ const keys = Object.keys(value)
258
+ for (let i = 0; i < keys.length; i++) {
259
+ const key = keys[i]
260
+ if (value[key] !== undefined) {
261
+ out[key] = clone(value[key])
262
+ }
263
+ }
264
+ return out
265
+ }
266
+
267
+ const createElement = ({ parent } = {}) => {
268
+ // Minimal BlitsElement mock used by generated render/effect code
269
+ const element = {
270
+ attributes: {},
271
+ children: [],
272
+ node: {
273
+ children: [],
274
+ on() {},
275
+ off() {},
276
+ },
277
+ parent,
278
+ populate(attributes = {}) {
279
+ this.nodeId = ++nodeId
280
+ const keys = Object.keys(attributes)
281
+ for (let i = 0; i < keys.length; i++) {
282
+ this.set(keys[i], attributes[keys[i]])
283
+ }
284
+ if (attributes[symbols.isSlot] !== undefined) {
285
+ this.attributes[symbols.isSlot] = attributes[symbols.isSlot]
286
+ }
287
+ if (attributes[symbols.isSprite] !== undefined) {
288
+ this.attributes[symbols.isSprite] = attributes[symbols.isSprite]
289
+ }
290
+ },
291
+ set(key, value) {
292
+ this.attributes[key] = value
293
+ this.node[key] = value
294
+ },
295
+ setInspectorMetadata() {},
296
+ destroy() {
297
+ this.eol = true
298
+ this.children.length = 0
299
+ this.node.children.length = 0
300
+ },
301
+ }
302
+
303
+ appendToParent(element, parent)
304
+
305
+ return element
306
+ }
307
+
308
+ const appendToParent = (element, parent) => {
309
+ if (parent === undefined || parent === null || parent === 'root') return
310
+ if (parent.children === undefined) return
311
+ parent.children.push(element)
312
+ if (parent.node && parent.node.children) {
313
+ parent.node.children.push(element.node)
314
+ }
315
+ }
316
+
317
+ const createKeyboardEvent = (key, init = {}) => {
318
+ const keyCode = init.keyCode || keyCodeFromKey(key)
319
+ return platform.createKeyboardEvent('keydown', {
320
+ key,
321
+ ...init,
322
+ keyCode,
323
+ bubbles: true,
324
+ cancelable: true,
325
+ composed: true,
326
+ })
327
+ }
328
+
329
+ const keyCodeFromKey = (key) => {
330
+ const keys = Object.keys(keyMap)
331
+ for (let i = 0; i < keys.length; i++) {
332
+ if (keyMap[keys[i]] === key) return Number(keys[i])
333
+ }
334
+ return 0
335
+ }
336
+
337
+ const isInComponentTree = (child, parent) => {
338
+ let current = child
339
+ while (current !== undefined && current !== null) {
340
+ if (current === parent) return true
341
+ current = current[symbols.parent]
342
+ }
343
+ return false
344
+ }
345
+
346
+ export default renderComponent