@pyreon/ui-core 0.11.1 → 0.11.2
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/package.json +8 -7
- package/src/PyreonUI.tsx +138 -0
- package/src/__tests__/PyreonUI.test.tsx +81 -0
- package/src/__tests__/compose.test.ts +32 -0
- package/src/__tests__/config.test.ts +102 -0
- package/src/__tests__/context.test.tsx +70 -0
- package/src/__tests__/hoistNonReactStatics.test.tsx +166 -0
- package/src/__tests__/isEmpty.test.ts +53 -0
- package/src/__tests__/isEqual.test.ts +114 -0
- package/src/__tests__/render.test.tsx +72 -0
- package/src/__tests__/useStableValue.test.ts +113 -0
- package/src/__tests__/utils.test.ts +537 -0
- package/src/compose.ts +11 -0
- package/src/config.ts +57 -0
- package/src/context.tsx +40 -0
- package/src/hoistNonReactStatics.ts +59 -0
- package/src/html/htmlElementAttrs.ts +106 -0
- package/src/html/htmlTags.ts +151 -0
- package/src/html/index.ts +11 -0
- package/src/index.ts +55 -0
- package/src/isEmpty.ts +20 -0
- package/src/isEqual.ts +27 -0
- package/src/render.tsx +44 -0
- package/src/types.ts +5 -0
- package/src/useStableValue.ts +21 -0
- package/src/utils.ts +157 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { get, merge, omit, pick, set, throttle } from "../utils"
|
|
3
|
+
|
|
4
|
+
// --------------------------------------------------------
|
|
5
|
+
// omit
|
|
6
|
+
// --------------------------------------------------------
|
|
7
|
+
describe("omit", () => {
|
|
8
|
+
it("should return object without specified keys", () => {
|
|
9
|
+
const obj = { a: 1, b: 2, c: 3 }
|
|
10
|
+
expect(omit(obj, ["b"])).toEqual({ a: 1, c: 3 })
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it("should return shallow copy when no keys specified", () => {
|
|
14
|
+
const obj = { a: 1, b: 2 }
|
|
15
|
+
const result = omit(obj)
|
|
16
|
+
expect(result).toEqual({ a: 1, b: 2 })
|
|
17
|
+
expect(result).not.toBe(obj)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("should return shallow copy when keys is empty array", () => {
|
|
21
|
+
const obj = { a: 1 }
|
|
22
|
+
expect(omit(obj, [])).toEqual({ a: 1 })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("should return empty object for null", () => {
|
|
26
|
+
expect(omit(null)).toEqual({})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("should return empty object for undefined", () => {
|
|
30
|
+
expect(omit(undefined)).toEqual({})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("should handle multiple keys", () => {
|
|
34
|
+
const obj = { a: 1, b: 2, c: 3, d: 4 }
|
|
35
|
+
expect(omit(obj, ["a", "c"])).toEqual({ b: 2, d: 4 })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("should ignore keys not present in object", () => {
|
|
39
|
+
const obj = { a: 1 }
|
|
40
|
+
expect(omit(obj, ["b", "c"])).toEqual({ a: 1 })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("should only include own properties", () => {
|
|
44
|
+
const proto = { inherited: true }
|
|
45
|
+
const obj = Object.create(proto)
|
|
46
|
+
obj.own = 1
|
|
47
|
+
expect(omit(obj, [])).toEqual({ own: 1 })
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// --------------------------------------------------------
|
|
52
|
+
// pick
|
|
53
|
+
// --------------------------------------------------------
|
|
54
|
+
describe("pick", () => {
|
|
55
|
+
it("should return object with only specified keys", () => {
|
|
56
|
+
const obj = { a: 1, b: 2, c: 3 }
|
|
57
|
+
expect(pick(obj, ["a", "c"])).toEqual({ a: 1, c: 3 })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("should return shallow copy when no keys specified", () => {
|
|
61
|
+
const obj = { a: 1, b: 2 }
|
|
62
|
+
const result = pick(obj)
|
|
63
|
+
expect(result).toEqual({ a: 1, b: 2 })
|
|
64
|
+
expect(result).not.toBe(obj)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("should return shallow copy when keys is empty array", () => {
|
|
68
|
+
const obj = { a: 1 }
|
|
69
|
+
expect(pick(obj, [])).toEqual({ a: 1 })
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("should return empty object for null", () => {
|
|
73
|
+
expect(pick(null)).toEqual({})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("should return empty object for undefined", () => {
|
|
77
|
+
expect(pick(undefined)).toEqual({})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("should ignore keys not present in object", () => {
|
|
81
|
+
const obj = { a: 1 }
|
|
82
|
+
expect(pick(obj, ["a", "b"])).toEqual({ a: 1 })
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("should only pick own properties", () => {
|
|
86
|
+
const proto = { inherited: true }
|
|
87
|
+
const obj = Object.create(proto)
|
|
88
|
+
obj.own = 1
|
|
89
|
+
expect(pick(obj, ["own", "inherited"])).toEqual({ own: 1 })
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// --------------------------------------------------------
|
|
94
|
+
// get
|
|
95
|
+
// --------------------------------------------------------
|
|
96
|
+
describe("get", () => {
|
|
97
|
+
it("should get nested value by dot path", () => {
|
|
98
|
+
const obj = { a: { b: { c: 42 } } }
|
|
99
|
+
expect(get(obj, "a.b.c")).toBe(42)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("should get value by array path", () => {
|
|
103
|
+
const obj = { a: { b: 10 } }
|
|
104
|
+
expect(get(obj, ["a", "b"])).toBe(10)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("should get array element by bracket notation", () => {
|
|
108
|
+
const obj = { items: [10, 20, 30] }
|
|
109
|
+
expect(get(obj, "items[1]")).toBe(20)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it("should return defaultValue when path does not exist", () => {
|
|
113
|
+
const obj = { a: 1 }
|
|
114
|
+
expect(get(obj, "b.c", "default")).toBe("default")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it("should return defaultValue when intermediate is null", () => {
|
|
118
|
+
const obj = { a: null }
|
|
119
|
+
expect(get(obj, "a.b", "fallback")).toBe("fallback")
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it("should return defaultValue when intermediate is undefined", () => {
|
|
123
|
+
const obj = { a: undefined }
|
|
124
|
+
expect(get(obj, "a.b", "fallback")).toBe("fallback")
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("should return undefined when path does not exist and no default", () => {
|
|
128
|
+
expect(get({}, "a.b.c")).toBeUndefined()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("should return the root value for empty array path", () => {
|
|
132
|
+
const obj = { a: 1 }
|
|
133
|
+
expect(get(obj, [])).toEqual({ a: 1 })
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("should handle top-level key", () => {
|
|
137
|
+
expect(get({ x: 5 }, "x")).toBe(5)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it("should return actual value even if it is falsy", () => {
|
|
141
|
+
expect(get({ a: 0 }, "a", "default")).toBe(0)
|
|
142
|
+
expect(get({ a: false }, "a", "default")).toBe(false)
|
|
143
|
+
expect(get({ a: "" }, "a", "default")).toBe("")
|
|
144
|
+
expect(get({ a: null }, "a", "default")).toBeNull()
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("should use defaultValue only when result is undefined", () => {
|
|
148
|
+
expect(get({ a: undefined }, "a", "default")).toBe("default")
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// UNSAFE_KEYS guard
|
|
152
|
+
it("should return defaultValue for __proto__ key", () => {
|
|
153
|
+
const obj = { a: 1 }
|
|
154
|
+
expect(get(obj, "__proto__", "safe")).toBe("safe")
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("should return defaultValue for prototype key", () => {
|
|
158
|
+
const obj = { a: 1 }
|
|
159
|
+
expect(get(obj, "prototype", "safe")).toBe("safe")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("should return defaultValue for constructor key", () => {
|
|
163
|
+
const obj = { a: 1 }
|
|
164
|
+
expect(get(obj, "constructor", "safe")).toBe("safe")
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it("should return defaultValue for __proto__ in nested path", () => {
|
|
168
|
+
const obj = { a: { b: 1 } }
|
|
169
|
+
expect(get(obj, "a.__proto__.c", "safe")).toBe("safe")
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it("should return obj itself for empty string path (no keys parsed)", () => {
|
|
173
|
+
// parsePath('') returns [] — no iteration — result stays as obj
|
|
174
|
+
// obj is not undefined — returned as-is
|
|
175
|
+
expect(get({ a: 1 }, "")).toEqual({ a: 1 })
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
// --------------------------------------------------------
|
|
180
|
+
// set
|
|
181
|
+
// --------------------------------------------------------
|
|
182
|
+
describe("set", () => {
|
|
183
|
+
it("should set nested value by dot path", () => {
|
|
184
|
+
const obj: any = {}
|
|
185
|
+
set(obj, "a.b.c", 42)
|
|
186
|
+
expect(obj.a.b.c).toBe(42)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("should set value by array path", () => {
|
|
190
|
+
const obj: any = {}
|
|
191
|
+
set(obj, ["x", "y"], 10)
|
|
192
|
+
expect(obj.x.y).toBe(10)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it("should create arrays for numeric keys", () => {
|
|
196
|
+
const obj: any = {}
|
|
197
|
+
set(obj, "items.0", "first")
|
|
198
|
+
expect(Array.isArray(obj.items)).toBe(true)
|
|
199
|
+
expect(obj.items[0]).toBe("first")
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("should overwrite existing values", () => {
|
|
203
|
+
const obj = { a: { b: 1 } }
|
|
204
|
+
set(obj, "a.b", 2)
|
|
205
|
+
expect(obj.a.b).toBe(2)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it("should return the mutated object", () => {
|
|
209
|
+
const obj = {}
|
|
210
|
+
const result = set(obj, "a", 1)
|
|
211
|
+
expect(result).toBe(obj)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("should handle single key path", () => {
|
|
215
|
+
const obj: any = {}
|
|
216
|
+
set(obj, "key", "value")
|
|
217
|
+
expect(obj.key).toBe("value")
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it("should not set anything for empty path", () => {
|
|
221
|
+
const obj = { a: 1 }
|
|
222
|
+
set(obj, "", "value")
|
|
223
|
+
expect(obj).toEqual({ a: 1 })
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// Security: prototype pollution protection
|
|
227
|
+
it("should not pollute Object.prototype via __proto__", () => {
|
|
228
|
+
const obj = {}
|
|
229
|
+
set(obj, "__proto__.polluted", true)
|
|
230
|
+
expect(({} as any).polluted).toBeUndefined()
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("should not pollute via constructor.prototype", () => {
|
|
234
|
+
const obj = {}
|
|
235
|
+
set(obj, "constructor.prototype.polluted", true)
|
|
236
|
+
expect(({} as any).polluted).toBeUndefined()
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it("should bail out when intermediate key is __proto__", () => {
|
|
240
|
+
const obj: any = { a: 1 }
|
|
241
|
+
const result = set(obj, "__proto__.polluted", true)
|
|
242
|
+
expect(result).toBe(obj)
|
|
243
|
+
expect(({} as any).polluted).toBeUndefined()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("should bail out when next key in path is unsafe", () => {
|
|
247
|
+
const obj: any = {}
|
|
248
|
+
const result = set(obj, "a.__proto__", "bad")
|
|
249
|
+
expect(result).toBe(obj)
|
|
250
|
+
expect(obj.a).toBeUndefined()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it("should bail out when last key is unsafe", () => {
|
|
254
|
+
const obj: any = {}
|
|
255
|
+
set(obj, "prototype", "bad")
|
|
256
|
+
// prototype is in UNSAFE_KEYS, so the set should be blocked
|
|
257
|
+
expect(obj.prototype).toBeUndefined()
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it("should handle bracket notation in paths", () => {
|
|
261
|
+
const obj: any = {}
|
|
262
|
+
set(obj, "items[0].name", "first")
|
|
263
|
+
expect(obj.items[0].name).toBe("first")
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it("should not overwrite existing intermediate objects", () => {
|
|
267
|
+
const obj: any = { a: { existing: true } }
|
|
268
|
+
set(obj, "a.b", "new")
|
|
269
|
+
expect(obj.a.existing).toBe(true)
|
|
270
|
+
expect(obj.a.b).toBe("new")
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// --------------------------------------------------------
|
|
275
|
+
// throttle
|
|
276
|
+
// --------------------------------------------------------
|
|
277
|
+
describe("throttle", () => {
|
|
278
|
+
beforeEach(() => {
|
|
279
|
+
vi.useFakeTimers()
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
afterEach(() => {
|
|
283
|
+
vi.useRealTimers()
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it("should call function immediately on first invocation", () => {
|
|
287
|
+
const fn = vi.fn()
|
|
288
|
+
const throttled = throttle(fn, 100)
|
|
289
|
+
throttled("a")
|
|
290
|
+
expect(fn).toHaveBeenCalledWith("a")
|
|
291
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it("should not call again within wait period", () => {
|
|
295
|
+
const fn = vi.fn()
|
|
296
|
+
const throttled = throttle(fn, 100)
|
|
297
|
+
throttled()
|
|
298
|
+
throttled()
|
|
299
|
+
throttled()
|
|
300
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it("should call with latest args after wait period", () => {
|
|
304
|
+
const fn = vi.fn()
|
|
305
|
+
const throttled = throttle(fn, 100)
|
|
306
|
+
|
|
307
|
+
throttled("first")
|
|
308
|
+
throttled("second")
|
|
309
|
+
throttled("third")
|
|
310
|
+
|
|
311
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
312
|
+
expect(fn).toHaveBeenCalledWith("first")
|
|
313
|
+
|
|
314
|
+
vi.advanceTimersByTime(100)
|
|
315
|
+
|
|
316
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
317
|
+
expect(fn).toHaveBeenLastCalledWith("third")
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it("should allow immediate call after wait period elapses", () => {
|
|
321
|
+
const fn = vi.fn()
|
|
322
|
+
const throttled = throttle(fn, 100)
|
|
323
|
+
|
|
324
|
+
throttled()
|
|
325
|
+
vi.advanceTimersByTime(100)
|
|
326
|
+
throttled()
|
|
327
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it("should cancel pending invocations", () => {
|
|
331
|
+
const fn = vi.fn()
|
|
332
|
+
const throttled = throttle(fn, 100)
|
|
333
|
+
|
|
334
|
+
throttled("first")
|
|
335
|
+
throttled("second")
|
|
336
|
+
throttled.cancel()
|
|
337
|
+
|
|
338
|
+
vi.advanceTimersByTime(200)
|
|
339
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it("should work with default wait of 0", () => {
|
|
343
|
+
const fn = vi.fn()
|
|
344
|
+
const throttled = throttle(fn)
|
|
345
|
+
throttled()
|
|
346
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
it("should skip trailing call when trailing: false", () => {
|
|
350
|
+
const fn = vi.fn()
|
|
351
|
+
const throttled = throttle(fn, 100, { trailing: false })
|
|
352
|
+
|
|
353
|
+
throttled("first")
|
|
354
|
+
throttled("second")
|
|
355
|
+
throttled("third")
|
|
356
|
+
|
|
357
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
358
|
+
expect(fn).toHaveBeenCalledWith("first")
|
|
359
|
+
|
|
360
|
+
vi.advanceTimersByTime(200)
|
|
361
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
it("should skip leading call when leading: false", () => {
|
|
365
|
+
const fn = vi.fn()
|
|
366
|
+
const throttled = throttle(fn, 100, { leading: false })
|
|
367
|
+
|
|
368
|
+
throttled("first")
|
|
369
|
+
expect(fn).toHaveBeenCalledTimes(0)
|
|
370
|
+
|
|
371
|
+
vi.advanceTimersByTime(100)
|
|
372
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
373
|
+
expect(fn).toHaveBeenCalledWith("first")
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
it("should support leading: false with trailing: true (default)", () => {
|
|
377
|
+
const fn = vi.fn()
|
|
378
|
+
const throttled = throttle(fn, 100, { leading: false })
|
|
379
|
+
|
|
380
|
+
throttled("a")
|
|
381
|
+
throttled("b")
|
|
382
|
+
expect(fn).toHaveBeenCalledTimes(0)
|
|
383
|
+
|
|
384
|
+
vi.advanceTimersByTime(100)
|
|
385
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
386
|
+
expect(fn).toHaveBeenCalledWith("b")
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it("should still fire leading call after cooldown with trailing: false", () => {
|
|
390
|
+
const fn = vi.fn()
|
|
391
|
+
const throttled = throttle(fn, 100, { trailing: false })
|
|
392
|
+
|
|
393
|
+
throttled("first")
|
|
394
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
395
|
+
|
|
396
|
+
vi.advanceTimersByTime(100)
|
|
397
|
+
throttled("second")
|
|
398
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
399
|
+
expect(fn).toHaveBeenLastCalledWith("second")
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
it("should not fire trailing call when cancelled before timer fires", () => {
|
|
403
|
+
const fn = vi.fn()
|
|
404
|
+
const throttled = throttle(fn, 100)
|
|
405
|
+
|
|
406
|
+
// Leading call fires immediately
|
|
407
|
+
throttled("first")
|
|
408
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
409
|
+
|
|
410
|
+
// Call again within wait period — queues trailing
|
|
411
|
+
throttled("second")
|
|
412
|
+
|
|
413
|
+
// Cancel before trailing timer fires
|
|
414
|
+
throttled.cancel()
|
|
415
|
+
|
|
416
|
+
vi.advanceTimersByTime(200)
|
|
417
|
+
// Should not have fired the trailing call
|
|
418
|
+
expect(fn).toHaveBeenCalledTimes(1)
|
|
419
|
+
})
|
|
420
|
+
|
|
421
|
+
it("should handle leading: false with trailing: false (no calls fire)", () => {
|
|
422
|
+
const fn = vi.fn()
|
|
423
|
+
const throttled = throttle(fn, 100, { leading: false, trailing: false })
|
|
424
|
+
|
|
425
|
+
throttled("a")
|
|
426
|
+
expect(fn).toHaveBeenCalledTimes(0)
|
|
427
|
+
|
|
428
|
+
vi.advanceTimersByTime(200)
|
|
429
|
+
expect(fn).toHaveBeenCalledTimes(0)
|
|
430
|
+
})
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
// --------------------------------------------------------
|
|
434
|
+
// merge
|
|
435
|
+
// --------------------------------------------------------
|
|
436
|
+
describe("merge", () => {
|
|
437
|
+
it("should deep merge two objects", () => {
|
|
438
|
+
const target = { a: { b: 1, c: 2 } }
|
|
439
|
+
const source = { a: { c: 3, d: 4 } }
|
|
440
|
+
expect(merge({ ...target }, source)).toEqual({ a: { b: 1, c: 3, d: 4 } })
|
|
441
|
+
})
|
|
442
|
+
|
|
443
|
+
it("should replace arrays instead of merging them", () => {
|
|
444
|
+
const target = { items: [1, 2, 3] }
|
|
445
|
+
const source = { items: [4, 5] }
|
|
446
|
+
expect(merge({ ...target }, source)).toEqual({ items: [4, 5] })
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it("should handle multiple sources", () => {
|
|
450
|
+
const result = merge({ a: 1 }, { b: 2 }, { c: 3 })
|
|
451
|
+
expect(result).toEqual({ a: 1, b: 2, c: 3 })
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it("should overwrite primitive values", () => {
|
|
455
|
+
expect(merge({ a: 1 }, { a: 2 })).toEqual({ a: 2 })
|
|
456
|
+
})
|
|
457
|
+
|
|
458
|
+
it("should skip null sources", () => {
|
|
459
|
+
const target = { a: 1 }
|
|
460
|
+
expect(merge(target, null as any)).toEqual({ a: 1 })
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
it("should skip undefined sources", () => {
|
|
464
|
+
const target = { a: 1 }
|
|
465
|
+
expect(merge(target, undefined as any)).toEqual({ a: 1 })
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it("should not merge non-plain objects deeply", () => {
|
|
469
|
+
const date = new Date()
|
|
470
|
+
const result = merge({} as any, { d: date })
|
|
471
|
+
expect(result.d).toBe(date)
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it("should return the target object (mutates)", () => {
|
|
475
|
+
const target = { a: 1 }
|
|
476
|
+
const result = merge(target, { b: 2 })
|
|
477
|
+
expect(result).toBe(target)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
it("should deeply merge nested objects", () => {
|
|
481
|
+
const target = { a: { b: { c: 1 } } }
|
|
482
|
+
const source = { a: { b: { d: 2 } } }
|
|
483
|
+
expect(merge({ ...target }, source)).toEqual({
|
|
484
|
+
a: { b: { c: 1, d: 2 } },
|
|
485
|
+
})
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
// Security: prototype pollution protection
|
|
489
|
+
it("should not pollute Object.prototype via __proto__", () => {
|
|
490
|
+
const malicious = JSON.parse('{"__proto__": {"polluted": true}}')
|
|
491
|
+
merge({}, malicious)
|
|
492
|
+
expect(({} as any).polluted).toBeUndefined()
|
|
493
|
+
})
|
|
494
|
+
|
|
495
|
+
it("should not pollute via constructor.prototype", () => {
|
|
496
|
+
const malicious = JSON.parse('{"constructor": {"prototype": {"polluted": true}}}')
|
|
497
|
+
merge({}, malicious)
|
|
498
|
+
expect(({} as any).polluted).toBeUndefined()
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
it("should not pollute via prototype key", () => {
|
|
502
|
+
const malicious = { prototype: { polluted: true } }
|
|
503
|
+
merge({}, malicious)
|
|
504
|
+
expect(({} as any).polluted).toBeUndefined()
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
it("should overwrite target plain object with source array", () => {
|
|
508
|
+
const target = { a: { b: 1 } }
|
|
509
|
+
const source = { a: [1, 2, 3] }
|
|
510
|
+
const result = merge({ ...target }, source as any)
|
|
511
|
+
expect(result.a).toEqual([1, 2, 3])
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
it("should overwrite target array with source plain object", () => {
|
|
515
|
+
const target = { a: [1, 2] } as any
|
|
516
|
+
const source = { a: { key: "value" } }
|
|
517
|
+
const result = merge({ ...target }, source)
|
|
518
|
+
expect(result.a).toEqual({ key: "value" })
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
it("should handle source with class instances (non-plain objects)", () => {
|
|
522
|
+
class MyClass {
|
|
523
|
+
x = 1
|
|
524
|
+
}
|
|
525
|
+
const instance = new MyClass()
|
|
526
|
+
const target = { a: { old: true } }
|
|
527
|
+
const result = merge({ ...target }, { a: instance } as any)
|
|
528
|
+
// class instances are not plain objects, so they replace (not deep merge)
|
|
529
|
+
expect(result.a).toBe(instance)
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it("should handle empty sources array", () => {
|
|
533
|
+
const target = { a: 1 }
|
|
534
|
+
const result = merge(target)
|
|
535
|
+
expect(result).toEqual({ a: 1 })
|
|
536
|
+
})
|
|
537
|
+
})
|
package/src/compose.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
type ArityOneFn = (arg: any) => any
|
|
2
|
+
type PickLastInTuple<T extends any[]> = T extends [...rest: infer _U, argn: infer L] ? L : any
|
|
3
|
+
type FirstFnParameterType<T extends any[]> = Parameters<PickLastInTuple<T>>[any]
|
|
4
|
+
type LastFnReturnType<T extends any[]> = ReturnType<T[0]>
|
|
5
|
+
|
|
6
|
+
const compose =
|
|
7
|
+
<T extends ArityOneFn[]>(...fns: T) =>
|
|
8
|
+
(p: FirstFnParameterType<T>): LastFnReturnType<T> =>
|
|
9
|
+
fns.reduceRight((acc: any, cur: any) => cur(acc), p)
|
|
10
|
+
|
|
11
|
+
export default compose
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { StyledFunction } from "@pyreon/styler"
|
|
2
|
+
import { css, keyframes, styled } from "@pyreon/styler"
|
|
3
|
+
import type { HTMLTags } from "./html"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Describes the shape of the CSS-in-JS engine.
|
|
7
|
+
* Pyreon uses @pyreon/styler directly — no connector abstraction needed.
|
|
8
|
+
* This type is kept for API compatibility with downstream packages.
|
|
9
|
+
*/
|
|
10
|
+
export interface CSSEngineConnector {
|
|
11
|
+
css: typeof css
|
|
12
|
+
styled: typeof styled
|
|
13
|
+
keyframes: typeof keyframes
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PlatformConfig {
|
|
17
|
+
component: string | HTMLTags
|
|
18
|
+
textComponent: string | HTMLTags
|
|
19
|
+
createMediaQueries?: (props: {
|
|
20
|
+
breakpoints: Record<string, number>
|
|
21
|
+
rootSize: number
|
|
22
|
+
css: CSSEngineConnector["css"]
|
|
23
|
+
}) => Record<string, (...args: any[]) => any>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type InitConfig = Partial<CSSEngineConnector & PlatformConfig>
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configuration singleton that bridges the UI system with the CSS engine.
|
|
30
|
+
* All packages reference config.css, config.styled, etc.
|
|
31
|
+
*
|
|
32
|
+
* In Pyreon, the engine is @pyreon/styler and is available immediately —
|
|
33
|
+
* no lazy initialization or connector pattern needed.
|
|
34
|
+
*/
|
|
35
|
+
class Configuration {
|
|
36
|
+
css = css
|
|
37
|
+
styled: StyledFunction = styled
|
|
38
|
+
keyframes = keyframes
|
|
39
|
+
component: string | HTMLTags = "div"
|
|
40
|
+
textComponent: string | HTMLTags = "span"
|
|
41
|
+
createMediaQueries: PlatformConfig["createMediaQueries"] = undefined
|
|
42
|
+
|
|
43
|
+
init = (props: InitConfig) => {
|
|
44
|
+
if (props.css) this.css = props.css
|
|
45
|
+
if (props.styled) this.styled = props.styled
|
|
46
|
+
if (props.keyframes) this.keyframes = props.keyframes
|
|
47
|
+
if (props.component) this.component = props.component
|
|
48
|
+
if (props.textComponent) this.textComponent = props.textComponent
|
|
49
|
+
if (props.createMediaQueries) this.createMediaQueries = props.createMediaQueries
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const config = new Configuration()
|
|
54
|
+
const { init } = config
|
|
55
|
+
|
|
56
|
+
export default config
|
|
57
|
+
export { init }
|
package/src/context.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { VNodeChild } from "@pyreon/core"
|
|
2
|
+
import { createContext, provide } from "@pyreon/core"
|
|
3
|
+
import isEmpty from "./isEmpty"
|
|
4
|
+
import type { Breakpoints } from "./types"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal context shared across all @pyreon packages.
|
|
8
|
+
* Carries the theme object plus any extra provider props.
|
|
9
|
+
*/
|
|
10
|
+
const context = createContext<any>({})
|
|
11
|
+
|
|
12
|
+
type Theme = Partial<
|
|
13
|
+
{
|
|
14
|
+
rootSize: number
|
|
15
|
+
breakpoints: Breakpoints
|
|
16
|
+
} & Record<string, any>
|
|
17
|
+
>
|
|
18
|
+
|
|
19
|
+
type ProviderType = Partial<
|
|
20
|
+
{
|
|
21
|
+
theme: Theme
|
|
22
|
+
children: VNodeChild
|
|
23
|
+
} & Record<string, any>
|
|
24
|
+
>
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Provider that feeds the internal Pyreon context with the theme.
|
|
28
|
+
* When no theme is supplied, renders children directly.
|
|
29
|
+
*/
|
|
30
|
+
function Provider({ theme, children, ...props }: ProviderType): VNodeChild {
|
|
31
|
+
if (isEmpty(theme) || !theme) return children ?? null
|
|
32
|
+
|
|
33
|
+
provide(context, { theme, ...props })
|
|
34
|
+
|
|
35
|
+
return children ?? null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { context }
|
|
39
|
+
|
|
40
|
+
export default Provider
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const KNOWN_STATICS: Record<string, true> = {
|
|
2
|
+
name: true,
|
|
3
|
+
length: true,
|
|
4
|
+
prototype: true,
|
|
5
|
+
caller: true,
|
|
6
|
+
callee: true,
|
|
7
|
+
arguments: true,
|
|
8
|
+
arity: true,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const COMPONENT_STATICS: Record<string, true> = {
|
|
12
|
+
displayName: true,
|
|
13
|
+
defaultProps: true,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Copies non-framework static properties from a source component to a target.
|
|
18
|
+
*
|
|
19
|
+
* Pyreon equivalent of hoistNonReactStatics — simplified since Pyreon
|
|
20
|
+
* components are plain functions without React-specific statics like
|
|
21
|
+
* contextType, propTypes, getDerivedStateFromProps, etc.
|
|
22
|
+
*/
|
|
23
|
+
const hoistNonReactStatics = <T, S>(
|
|
24
|
+
target: T,
|
|
25
|
+
source: S,
|
|
26
|
+
excludeList?: Record<string, true>,
|
|
27
|
+
): T => {
|
|
28
|
+
if (typeof source === "string") return target
|
|
29
|
+
|
|
30
|
+
const proto = Object.getPrototypeOf(source)
|
|
31
|
+
if (proto && proto !== Object.prototype) {
|
|
32
|
+
hoistNonReactStatics(target, proto, excludeList)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const keys: (string | symbol)[] = [
|
|
36
|
+
...Object.getOwnPropertyNames(source),
|
|
37
|
+
...Object.getOwnPropertySymbols(source),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for (const key of keys) {
|
|
41
|
+
const k = key as string
|
|
42
|
+
if (KNOWN_STATICS[k] || excludeList?.[k] || COMPONENT_STATICS[k]) {
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const descriptor = Object.getOwnPropertyDescriptor(source, key)
|
|
47
|
+
if (descriptor) {
|
|
48
|
+
try {
|
|
49
|
+
Object.defineProperty(target, key, descriptor)
|
|
50
|
+
} catch {
|
|
51
|
+
// Silently skip non-configurable properties
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return target
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default hoistNonReactStatics
|