@lightningjs/blits 2.5.0 → 2.7.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.
@@ -0,0 +1,396 @@
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 { setDevMode } from '../component.js'
21
+ import symbols from '../lib/symbols.js'
22
+ import { initLog } from '../lib/log.js'
23
+ import { getRaw } from '../lib/reactivity/reactive.js'
24
+ import Focus from '../focus/focus.js'
25
+ import { initKeyMap, keyMap } from '../application.js'
26
+ import { configurePlatform, platform } from '../platform.js'
27
+ import { isObjectString, parseToObject } from '../lib/utils.js'
28
+ import nodePlatform from './nodePlatform.js'
29
+
30
+ let nodeId = 0
31
+
32
+ const renderComponent = (Component, options = {}) => {
33
+ const originalElement = stage.element
34
+ const originalPlatform = platform
35
+
36
+ Settings.set(options.settings || {})
37
+ setDevMode(true)
38
+ configurePlatform(() => nodePlatform())
39
+ initLog()
40
+ initKeyMap()
41
+ mockRenderer()
42
+ // Generated template code calls stage.element through `this.element`
43
+ stage.element = createElement
44
+
45
+ const root = createElement()
46
+ const parent = {
47
+ $componentId: 'BlitsTestComponent::Root',
48
+ [symbols.lifecycle]: {
49
+ set state(v) {},
50
+ },
51
+ $focus() {},
52
+ $input() {
53
+ return false
54
+ },
55
+ }
56
+ const component = Component({ props: options.props || {} }, root, parent)
57
+
58
+ const snapshot = () => {
59
+ const holderMap = new WeakMap()
60
+ collectComponents(component, holderMap)
61
+ return componentSnapshot(component, holderMap)
62
+ }
63
+
64
+ const findAllByData = (key, value) => {
65
+ const matches = []
66
+ visitSnapshot(snapshot(), (node) => {
67
+ if (node.attributes && node.attributes.data && node.attributes.data[key] === value) {
68
+ matches.push(node)
69
+ }
70
+ })
71
+ return matches
72
+ }
73
+
74
+ const findByData = (key, value) => {
75
+ let match = null
76
+ visitSnapshot(snapshot(), (node) => {
77
+ if (node.attributes && node.attributes.data && node.attributes.data[key] === value) {
78
+ match = node
79
+ return true
80
+ }
81
+ })
82
+ return match
83
+ }
84
+
85
+ const setProps = (props = {}) => {
86
+ const keys = Object.keys(props)
87
+ for (let i = 0; i < keys.length; i++) {
88
+ component[symbols.props][keys[i]] = props[keys[i]]
89
+ }
90
+ }
91
+
92
+ const setState = (state = {}) => {
93
+ const keys = Object.keys(state)
94
+ for (let i = 0; i < keys.length; i++) {
95
+ component[symbols.state][keys[i]] = state[keys[i]]
96
+ }
97
+ }
98
+
99
+ const focus = (event) => {
100
+ Focus.hold = false
101
+ component.$focus(event)
102
+ return new Promise((resolve) => {
103
+ setTimeout(() => {
104
+ setTimeout(() => resolve(component))
105
+ })
106
+ })
107
+ }
108
+
109
+ const unfocus = () => {
110
+ component.unfocus()
111
+ return component
112
+ }
113
+
114
+ const input = (key, event = createKeyboardEvent(key)) => {
115
+ const focused = Focus.get()
116
+ if (focused === null || focused.eol === true || isInComponentTree(focused, component) === false)
117
+ return false
118
+ Focus.input(key, event)
119
+ return true
120
+ }
121
+
122
+ const destroy = () => {
123
+ if (component.eol !== true) {
124
+ component.destroy()
125
+ }
126
+ stage.element = originalElement
127
+ configurePlatform(() => originalPlatform)
128
+ }
129
+
130
+ return {
131
+ component,
132
+ root,
133
+ snapshot,
134
+ findByData,
135
+ findAllByData,
136
+ setProps,
137
+ setState,
138
+ focus,
139
+ unfocus,
140
+ createKeyboardEvent,
141
+ input,
142
+ destroy,
143
+ }
144
+ }
145
+
146
+ const mockRenderer = () => {
147
+ // Lifecycle hooks register renderer events even in headless tests
148
+ if (renderer.on === undefined) renderer.on = () => {}
149
+ if (renderer.off === undefined) renderer.off = () => {}
150
+ }
151
+
152
+ const collectComponents = (component, holderMap) => {
153
+ if (component === undefined || component === null) return
154
+ // Component tags render as holder elements; keep the link for snapshots
155
+ if (component[symbols.holder] !== undefined) {
156
+ holderMap.set(component[symbols.holder], component)
157
+ }
158
+ const children = component[symbols.children] || []
159
+ for (let i = 0; i < children.length; i++) {
160
+ const child = children[i]
161
+ if (child && child.$componentId !== undefined) {
162
+ collectComponents(child, holderMap)
163
+ }
164
+ }
165
+ }
166
+
167
+ const elementSnapshot = (element, holderMap) => {
168
+ const component = holderMap.get(element)
169
+ if (component !== undefined) {
170
+ return componentSnapshot(component, holderMap)
171
+ }
172
+
173
+ const children = []
174
+ for (let i = 0; i < element.children.length; i++) {
175
+ children.push(elementSnapshot(element.children[i], holderMap))
176
+ }
177
+
178
+ return {
179
+ type: elementType(element.attributes),
180
+ attributes: attributesSnapshot(element.attributes),
181
+ children,
182
+ }
183
+ }
184
+
185
+ const componentSnapshot = (component, holderMap) => {
186
+ const holder = component[symbols.holder]
187
+ const wrapper = component[symbols.wrapper]
188
+
189
+ return {
190
+ type: 'Component',
191
+ name: componentName(component),
192
+ hasFocus: component.$hasFocus === true,
193
+ isHovered: component.$isHovered === true,
194
+ attributes: holder ? attributesSnapshot(holder.attributes) : {},
195
+ props: propsSnapshot(component),
196
+ state: stateSnapshot(component),
197
+ tree: wrapper ? elementSnapshot(wrapper, holderMap) : null,
198
+ }
199
+ }
200
+
201
+ const attributesSnapshot = (attributes) => {
202
+ const out = {}
203
+ const keys = Object.keys(attributes)
204
+ for (let i = 0; i < keys.length; i++) {
205
+ const key = keys[i]
206
+ if (
207
+ key === '___wrapper' ||
208
+ key === '__textnode' ||
209
+ key === '__layout' ||
210
+ key === 'holder' ||
211
+ key === 'parent'
212
+ ) {
213
+ continue
214
+ }
215
+ if (attributes[key] !== undefined) {
216
+ out[key] = clone(attributes[key])
217
+ }
218
+ }
219
+ return out
220
+ }
221
+
222
+ const propsSnapshot = (component) => {
223
+ const props = component[symbols.props] || {}
224
+ const propKeys = component[symbols.propKeys] || Object.keys(props)
225
+ const out = {}
226
+ for (let i = 0; i < propKeys.length; i++) {
227
+ const key = propKeys[i]
228
+ if (props[key] !== undefined) {
229
+ out[key] = clone(props[key])
230
+ }
231
+ }
232
+ return out
233
+ }
234
+
235
+ const stateSnapshot = (component) => {
236
+ const state = component[symbols.state] || {}
237
+ const originalState = component[symbols.originalState] || {}
238
+ const stateKeys = Object.keys(originalState)
239
+ const out = {}
240
+ for (let i = 0; i < stateKeys.length; i++) {
241
+ const key = stateKeys[i]
242
+ if (key === '$hasFocus' || key === '$isHovered') continue
243
+ if (state[key] !== undefined) {
244
+ out[key] = clone(state[key])
245
+ }
246
+ }
247
+ return out
248
+ }
249
+
250
+ const componentName = (component) => {
251
+ const match = component.$componentId && component.$componentId.match(/^BlitsComponent::(.+)_\d+$/)
252
+ return match ? match[1] : 'Component'
253
+ }
254
+
255
+ const elementType = (attributes) => {
256
+ if (attributes.__textnode === true) return 'Text'
257
+ if (attributes.__layout === true) return 'Layout'
258
+ if (attributes[symbols.isSlot] === true) return 'Slot'
259
+ if (attributes[symbols.isSprite] === true) return 'Sprite'
260
+ return 'Element'
261
+ }
262
+
263
+ const clone = (value) => {
264
+ value = getRaw(value)
265
+
266
+ if (Array.isArray(value) === true) {
267
+ return value.map((v) => clone(v))
268
+ }
269
+
270
+ if (
271
+ value !== null &&
272
+ typeof value === 'object' &&
273
+ Object.getPrototypeOf(value) === Object.prototype
274
+ ) {
275
+ return cleanObject(value)
276
+ }
277
+
278
+ return value
279
+ }
280
+
281
+ const cleanObject = (value) => {
282
+ const out = {}
283
+ const keys = Object.keys(value)
284
+ for (let i = 0; i < keys.length; i++) {
285
+ const key = keys[i]
286
+ if (value[key] !== undefined) {
287
+ out[key] = clone(value[key])
288
+ }
289
+ }
290
+ return out
291
+ }
292
+
293
+ const createElement = ({ parent } = {}) => {
294
+ // Minimal BlitsElement mock used by generated render/effect code
295
+ const element = {
296
+ attributes: {},
297
+ children: [],
298
+ node: {
299
+ children: [],
300
+ on() {},
301
+ off() {},
302
+ },
303
+ parent,
304
+ populate(attributes = {}) {
305
+ this.nodeId = ++nodeId
306
+ const keys = Object.keys(attributes)
307
+ for (let i = 0; i < keys.length; i++) {
308
+ this.set(keys[i], attributes[keys[i]])
309
+ }
310
+ if (attributes[symbols.isSlot] !== undefined) {
311
+ this.attributes[symbols.isSlot] = attributes[symbols.isSlot]
312
+ }
313
+ if (attributes[symbols.isSprite] !== undefined) {
314
+ this.attributes[symbols.isSprite] = attributes[symbols.isSprite]
315
+ }
316
+ },
317
+ set(key, value) {
318
+ if (key === 'inspector-data') {
319
+ this.setInspectorMetadata(value)
320
+ return
321
+ }
322
+ this.attributes[key] = value
323
+ this.node[key] = value
324
+ },
325
+ setInspectorMetadata(data) {
326
+ if (isObjectString(data) === true) {
327
+ data = parseToObject(data)
328
+ }
329
+ if (data === null || typeof data !== 'object') return
330
+ this.attributes.data = { ...(this.attributes.data || {}), ...data }
331
+ this.node.data = { ...(this.node.data || {}), ...data }
332
+ },
333
+ destroy() {
334
+ this.eol = true
335
+ this.children.length = 0
336
+ this.node.children.length = 0
337
+ },
338
+ }
339
+
340
+ appendToParent(element, parent)
341
+
342
+ return element
343
+ }
344
+
345
+ const appendToParent = (element, parent) => {
346
+ if (parent === undefined || parent === null || parent === 'root') return
347
+ if (parent.children === undefined) return
348
+ parent.children.push(element)
349
+ if (parent.node && parent.node.children) {
350
+ parent.node.children.push(element.node)
351
+ }
352
+ }
353
+
354
+ const visitSnapshot = (node, visit) => {
355
+ if (node === undefined || node === null) return false
356
+ if (visit(node) === true) return true
357
+ if (node.type === 'Component') {
358
+ return visitSnapshot(node.tree, visit)
359
+ }
360
+ const children = node.children || []
361
+ for (let i = 0; i < children.length; i++) {
362
+ if (visitSnapshot(children[i], visit) === true) return true
363
+ }
364
+ return false
365
+ }
366
+
367
+ const createKeyboardEvent = (key, init = {}) => {
368
+ const keyCode = init.keyCode || keyCodeFromKey(key)
369
+ return platform.createKeyboardEvent('keydown', {
370
+ key,
371
+ ...init,
372
+ keyCode,
373
+ bubbles: true,
374
+ cancelable: true,
375
+ composed: true,
376
+ })
377
+ }
378
+
379
+ const keyCodeFromKey = (key) => {
380
+ const keys = Object.keys(keyMap)
381
+ for (let i = 0; i < keys.length; i++) {
382
+ if (keyMap[keys[i]] === key) return Number(keys[i])
383
+ }
384
+ return 0
385
+ }
386
+
387
+ const isInComponentTree = (child, parent) => {
388
+ let current = child
389
+ while (current !== undefined && current !== null) {
390
+ if (current === parent) return true
391
+ current = current[symbols.parent]
392
+ }
393
+ return false
394
+ }
395
+
396
+ export default renderComponent