spyne 0.25.0 → 0.26.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.
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Spyne 0.25.0
2
+ * Spyne 0.26.0
3
3
  * https://spynejs.org
4
4
  *
5
5
  * @license
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  /*!
18
- * spynejs 0.25.0
18
+ * spynejs 0.26.0
19
19
  * https://spynejs.org
20
20
  * (c) 2017-present Frank Batista
21
21
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spyne",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Reactive Real-DOM Framework for Advanced Javascript applications",
5
5
  "sideEffects": true,
6
6
  "main": "./lib/spyne.esm.js",
@@ -12,7 +12,11 @@ import {
12
12
  is
13
13
  } from 'ramda'
14
14
  import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
15
- import { safeClone } from '../utils/safe-clone.js'
15
+ import { safeClone, safeCloneDeep } from '../utils/safe-clone.js'
16
+
17
+ // read-only facades for cms-proxied payload subtrees, cached per proxy so
18
+ // repeated dispatches of the same canonical data reuse a single wrapper
19
+ const _readOnlyFacadeCache = new WeakMap()
16
20
 
17
21
  export class ChannelPayload {
18
22
  /**
@@ -45,8 +49,7 @@ export class ChannelPayload {
45
49
 
46
50
  const channelPayloadItemObj = { channelName, action, srcElement, event }
47
51
  // Object.defineProperty(channelPayloadItemObj, 'payload', {get: () => clone(payload)});
48
- const frozenPayload = ChannelPayload.deepFreeze(payload)
49
- channelPayloadItemObj.payload = frozenPayload
52
+ channelPayloadItemObj.payload = ChannelPayload.deepFreeze(payload)
50
53
  /**
51
54
  * This is a convenience method that helps with destructuring by merging all properties.
52
55
  *
@@ -71,14 +74,47 @@ export class ChannelPayload {
71
74
  ChannelPayload.validateAction(action, channel, channelActionsArr)
72
75
  }
73
76
 
74
- channelPayloadItemObj.clone = () => mergeAll([
75
- { payload:safeClone(channelPayloadItemObj.payload) },
76
- channelPayloadItemObj.payload,
77
- { channel: clone(channel) },
78
- { event: clone(event) },
79
- { srcElement },
80
- clone(channelPayloadItemObj.srcElement), { action: clone(channelPayloadItemObj.action) }
81
- ])
77
+ channelPayloadItemObj.clone = () => {
78
+ return mergeAll([
79
+ { payload:safeClone(channelPayloadItemObj.payload) },
80
+ channelPayloadItemObj.payload,
81
+ { channel: clone(channel) },
82
+ { event: clone(event) },
83
+ { srcElement },
84
+ clone(channelPayloadItemObj.srcElement), { action: clone(channelPayloadItemObj.action) }
85
+ ])
86
+ }
87
+
88
+ /**
89
+ * Proxy-preserving version of clone. Returns the same merged shape, but
90
+ * the payload is copied with safeCloneDeep, so any CMS proxy objects —
91
+ * at the root or nested inside plain containers or arrays — are revived
92
+ * as writable proxy copies instead of being stripped by a structural
93
+ * clone. Use this when a handler treats the payload as its data source.
94
+ *
95
+ * @returns
96
+ * JSON Object
97
+ *
98
+ * @example
99
+ * TITLE['<h4>Retrieving Proxied Payload Data</h4>']
100
+ * onStoryEvent(e){
101
+ * const data = e.safeClone();
102
+ * this.addStoryArticle(data);
103
+ * }
104
+ *
105
+ */
106
+ channelPayloadItemObj.safeClone = () => {
107
+ const revivedPayload = safeCloneDeep(channelPayloadItemObj.payload)
108
+ return mergeAll([
109
+ { payload: revivedPayload },
110
+ revivedPayload,
111
+ { channel },
112
+ { event },
113
+ { srcElement },
114
+ channelPayloadItemObj.srcElement,
115
+ { action: channelPayloadItemObj.action }
116
+ ])
117
+ }
82
118
 
83
119
  const channelPayloadItemObjProps = {
84
120
  $dir: {
@@ -145,22 +181,81 @@ export class ChannelPayload {
145
181
  return isIterable ? compose(fromPairs, toPairs, clone)(o) : clone(o)
146
182
  }
147
183
 
184
+ /**
185
+ * Wraps a CMS-proxied payload subtree in a read-only facade. Payloads are
186
+ * reference-by-wire (every subscriber shares the same object), and freezing
187
+ * a proxy would freeze the CANONICAL cms target for the whole app — so
188
+ * instead the payload boundary gets a lightweight second proxy that blocks
189
+ * writes while reads (and nested reads) pass through. Consumers that need
190
+ * a writable copy call e.safeClone(). Facades are cached per proxy so
191
+ * repeated dispatches of the same data reuse one wrapper.
192
+ */
193
+ static createReadOnlyProxyFacade(proxyObj) {
194
+ if (_readOnlyFacadeCache.has(proxyObj)) {
195
+ return _readOnlyFacadeCache.get(proxyObj)
196
+ }
197
+
198
+ const warnBlockedWrite = (propName) => {
199
+ if (SpyneAppProperties.debug === true) {
200
+ console.warn(`Spyne Warning: blocked write of '${String(propName)}' on read-only channel payload data; use e.safeClone() for a writable copy`)
201
+ }
202
+ return true // silent no-op; returning false would throw in strict mode
203
+ }
204
+
205
+ const facade = new Proxy(proxyObj, {
206
+ get(target, propName) {
207
+ const val = target[propName]
208
+ // nested cms containers are proxies themselves — wrap them so deep
209
+ // writes are blocked too (cached, so repeat reads reuse the facade)
210
+ if (val !== null && typeof val === 'object' && val.__proxy__isProxy === true) {
211
+ return ChannelPayload.createReadOnlyProxyFacade(val)
212
+ }
213
+ return val
214
+ },
215
+ set(target, propName) {
216
+ return warnBlockedWrite(propName)
217
+ },
218
+ defineProperty(target, propName) {
219
+ return warnBlockedWrite(propName)
220
+ },
221
+ deleteProperty(target, propName) {
222
+ return warnBlockedWrite(propName)
223
+ }
224
+ })
225
+
226
+ _readOnlyFacadeCache.set(proxyObj, facade)
227
+ return facade
228
+ }
229
+
148
230
  static deepFreeze(o) {
149
231
  // return o;
150
232
  // return Object.freeze(o);
151
233
  const elIsDomElement = compose(lte(0), defaultTo(-1), prop('nodeType'))
152
234
 
235
+ // CMS proxy subtrees are NOT frozen (that would freeze the canonical cms
236
+ // target app-wide) — they are wrapped in a read-only facade instead, so
237
+ // the payload immutability contract holds without copies
238
+ if (o !== null && typeof o === 'object' && o.__proxy__isProxy === true) {
239
+ return ChannelPayload.createReadOnlyProxyFacade(o)
240
+ }
241
+
153
242
  try {
154
- Object.freeze(o)
243
+ // children first: proxy subtrees must be swapped for their facades
244
+ // BEFORE the parent is frozen, or the assignment would be rejected
155
245
  Object.getOwnPropertyNames(o).forEach(function(prop) {
246
+ const val = o[prop]
156
247
  if (Object.prototype.hasOwnProperty.call(o, prop) &&
157
- elIsDomElement(o[prop]) === false &&
158
- o[prop] !== null &&
159
- (typeof o[prop] === 'object' || typeof o[prop] === 'function') &&
160
- !Object.isFrozen(o[prop])) {
161
- ChannelPayload.deepFreeze(o[prop])
248
+ elIsDomElement(val) === false &&
249
+ val !== null &&
250
+ (typeof val === 'object' || typeof val === 'function') &&
251
+ !Object.isFrozen(val)) {
252
+ const processed = ChannelPayload.deepFreeze(val)
253
+ if (processed !== val) {
254
+ o[prop] = processed
255
+ }
162
256
  }
163
257
  })
258
+ Object.freeze(o)
164
259
  } catch (e) {
165
260
  // console.log("FREEZE ERR ",{o,e});
166
261
  return o
@@ -1,5 +1,6 @@
1
1
  import { registeredStreamNames } from './channels-config.js'
2
2
  import { ChannelPayload } from './channel-payload-class.js'
3
+ import { safeCloneDeep } from '../utils/safe-clone.js'
3
4
  import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
4
5
  import { RouteChannelUpdater } from '../utils/route-channel-updater.js'
5
6
 
@@ -318,6 +319,18 @@ export class Channel {
318
319
  const mergeProps = (d) => mergeAll([d, { action: prop('action', d) }, prop('payload', d), prop('srcElement', d)])
319
320
  const dataObj = obsVal => ({
320
321
  clone: () => mergeProps(obj.data),
322
+ // proxy-preserving clone — mirrors ChannelPayload.safeClone for
323
+ // channel-side getChannel subscribers
324
+ safeClone: () => {
325
+ const revivedPayload = safeCloneDeep(payload)
326
+ return mergeAll([
327
+ { payload: revivedPayload },
328
+ revivedPayload,
329
+ { action },
330
+ { srcElement },
331
+ { event: obsVal }
332
+ ])
333
+ },
321
334
  action,
322
335
  payload,
323
336
  srcElement,
@@ -3,10 +3,12 @@ import { checkIfObjIsNotEmptyOrNil } from '../utils/frp-tools.js'
3
3
  import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
4
4
  import { SpyneUtilsChannelWindow } from '../utils/spyne-utils-channel-window.js'
5
5
  import { merge } from 'rxjs'
6
- import { map, debounceTime, skipWhile } from 'rxjs/operators'
6
+ import { map, debounceTime, skipWhile, buffer, bufferCount, throttleTime, filter } from 'rxjs/operators'
7
7
  import { curry, pick, partialRight, mapObjIndexed, apply, map as rMap } from 'ramda'
8
8
  import { deepMerge } from '../utils/deep-merge.js'
9
9
 
10
+ const customActionsArr = []
11
+
10
12
  export class SpyneChannelWindow extends Channel {
11
13
  /**
12
14
  * @module SpyneChannelWindow
@@ -21,6 +23,7 @@ export class SpyneChannelWindow extends Channel {
21
23
  * @param {Object} config
22
24
  * @property {Object} config - = {}; The config has several options used to listen to window and document events.
23
25
  * @property {Array} config.events - = []; Any window and document events can be added here.
26
+ * @property {Array} config.customEvents - = []; Application CustomEvents to capture. Each entry is either an event name string, or an object with a name property plus ONE rate/batch option: buffer (ms of quiet that closes a batch; emits one payload whose payload.detail is the array of batched details), count (batch every n events), debounce (ms; emits only the latest event), or throttle (ms; emits the leading event per window). Example: ['simple_event', {name: 'spyne_cms_item_connected', buffer: 400}].
24
27
  * @property {Object} config.mediaQueries - = {}; Media queries are added as key,value pairs; the key is the name of the boolean for the query, the value is the query itself.
25
28
  * @property {Boolean} config.listenForResize - = true; This is default listening for resize event.
26
29
  * @property {Boolean} config.listenForOrientation - = true; Listen for horizontal and landscape orientation changes on mobile devices.
@@ -181,12 +184,88 @@ export class SpyneChannelWindow extends Channel {
181
184
  getEventsFromConfig(config = this.domChannelConfig) {
182
185
  const obs$Arr = config.events
183
186
 
187
+ const customEntries = Array.isArray(config.customEvents)
188
+ ? config.customEvents.map(SpyneChannelWindow.conformCustomEventConfig).filter(entry => entry !== null)
189
+ : []
190
+
191
+ customEntries.forEach(entry => this.updateChannelActions(entry.name))
192
+
184
193
  const addWindowEventToArr = str => {
185
194
  const mapFn = SpyneChannelWindow.createCurriedGenericEvent(str)
186
195
  return SpyneUtilsChannelWindow.createDomObservableFromEvent(str, mapFn)
187
196
  }
188
197
 
189
- return rMap(addWindowEventToArr, obs$Arr)
198
+ const addCustomEventToArr = ({ name, operator, value }) => {
199
+ const obs$ = addWindowEventToArr(name)
200
+ return operator === undefined ? obs$ : SpyneChannelWindow.customizeCustomEventObservable(obs$, operator, value)
201
+ }
202
+
203
+ return rMap(addWindowEventToArr, obs$Arr).concat(rMap(addCustomEventToArr, customEntries))
204
+ }
205
+
206
+ /**
207
+ * Normalizes a config.customEvents entry — an event name string or a
208
+ * {name, <operator>} object — to {name, operator, value}. Complexity is
209
+ * capped at one level: a single operator key (buffer, count, debounce or
210
+ * throttle); when several are set the first in that order wins.
211
+ */
212
+ static conformCustomEventConfig(entry) {
213
+ if (typeof entry === 'string') {
214
+ return { name: entry, operator: undefined, value: undefined }
215
+ }
216
+
217
+ if (entry !== null && typeof entry === 'object' && typeof entry.name === 'string') {
218
+ const operatorKeys = ['buffer', 'count', 'debounce', 'throttle']
219
+ const setKeys = operatorKeys.filter(key => entry[key] !== undefined)
220
+ const operator = setKeys[0]
221
+
222
+ if (setKeys.length > 1 && SpyneAppProperties.debug === true) {
223
+ console.warn(`Spyne Warning: the customEvent, ${entry.name}, sets multiple operators (${setKeys.join(', ')}); using ${operator}.`)
224
+ }
225
+
226
+ return { name: entry.name, operator, value: operator !== undefined ? entry[operator] : undefined }
227
+ }
228
+
229
+ console.warn(`Spyne Warning: the following config.customEvents entry is not a string or a {name} object and was skipped: ${JSON.stringify(entry)}`)
230
+ return null
231
+ }
232
+
233
+ /**
234
+ * Applies the entry's rate/batch operator to the conformed CustomEvent
235
+ * observable. buffer and count emit ONE batched payload (payload.detail is
236
+ * the array of each event's detail); debounce and throttle pass single
237
+ * conformed events through untouched.
238
+ */
239
+ static customizeCustomEventObservable(obs$, operator, value) {
240
+ switch (operator) {
241
+ case 'buffer':
242
+ // quiet-gap batching: the batch closes after `value` ms without a new event
243
+ return obs$.pipe(
244
+ buffer(obs$.pipe(debounceTime(value))),
245
+ filter(pArr => pArr.length > 0),
246
+ map(SpyneChannelWindow.mapBatchedEvents)
247
+ )
248
+ case 'count':
249
+ // fixed-size batching: a partial batch is held until it fills
250
+ return obs$.pipe(
251
+ bufferCount(value),
252
+ map(SpyneChannelWindow.mapBatchedEvents)
253
+ )
254
+ case 'debounce':
255
+ return obs$.pipe(debounceTime(value))
256
+ case 'throttle':
257
+ return obs$.pipe(throttleTime(value))
258
+ default:
259
+ return obs$
260
+ }
261
+ }
262
+
263
+ static mapBatchedEvents(pArr) {
264
+ const last = pArr[pArr.length - 1]
265
+ const { action, srcElement, event } = last
266
+ const detail = pArr.map(p => p.event !== undefined && p.event !== null ? p.event.detail : undefined)
267
+ const payload = { detail, isBatch: true, batchCount: pArr.length }
268
+ return { action, payload, srcElement, event }
190
269
  }
191
270
 
192
271
  getActiveObservables(config = this.domChannelConfig) {
@@ -245,8 +324,19 @@ export class SpyneChannelWindow extends Channel {
245
324
  return obs$.pipe(map(this.getMediaQueryMapFn.bind(this)))
246
325
  }
247
326
 
327
+ updateChannelActions(str) {
328
+ if (typeof str !== 'string') {
329
+ return
330
+ }
331
+ function getWindowAction() {
332
+ return `CHANNEL_WINDOW_${str.toUpperCase()}_EVENT`
333
+ }
334
+ const windowAction = getWindowAction()
335
+ customActionsArr.push(windowAction)
336
+ }
337
+
248
338
  addRegisteredActions() {
249
- return [
339
+ const mainActions = [
250
340
  'CHANNEL_WINDOW_ABORT_EVENT',
251
341
  'CHANNEL_WINDOW_AFTERPRINT_EVENT',
252
342
  'CHANNEL_WINDOW_ANIMATIONEND_EVENT',
@@ -334,6 +424,8 @@ export class SpyneChannelWindow extends Channel {
334
424
  'CHANNEL_WINDOW_UNLOAD_EVENT',
335
425
  'CHANNEL_WINDOW_WHEEL_EVENT'
336
426
  ]
427
+
428
+ return mainActions.concat(customActionsArr)
337
429
  }
338
430
 
339
431
  onScrollLockEvent(e) {
@@ -6,7 +6,7 @@ import { sanitizeHTMLConfigure } from './utils/sanitize-html.js'
6
6
  import { sanitizeDataConfigure } from './utils/sanitize-data.js'
7
7
 
8
8
  const _channels = new ChannelsMap()
9
- const version = '0.25.0'
9
+ const version = '0.26.0'
10
10
 
11
11
  class SpyneApplication {
12
12
  /**
@@ -41,7 +41,7 @@ class SpyneApplication {
41
41
  init(config = {}, testMode = false) {
42
42
  // this.channels = new ChannelsMap();
43
43
  /*!
44
- * Spyne 0.25.0
44
+ * Spyne 0.26.0
45
45
  * https://spynejs.org
46
46
  *
47
47
  * @license
@@ -77,6 +77,7 @@ class SpyneApplication {
77
77
  mode: 'app',
78
78
  disableSanitize: false,
79
79
  iframes: {},
80
+ anchors: {},
80
81
  customElements: [],
81
82
  baseHref: undefined,
82
83
  IMG_PATH: imgPath,
@@ -95,6 +96,7 @@ class SpyneApplication {
95
96
 
96
97
  },
97
98
  events: [],
99
+ customEvents: [],
98
100
  listenForResize: true,
99
101
  listenForOrientation: true,
100
102
  listenForScroll: false,
@@ -128,16 +128,33 @@ export class SpynePlugin {
128
128
 
129
129
  checkForRequiredWindowEvent() {
130
130
  const requiredEvents = this.props.requiredEvents ?? []
131
+ const requiredCustomEvents = this.props.requiredCustomEvents ?? []
132
+
131
133
  const windowConfig = SpyneAppProperties.getChannelConfig('WINDOW')
132
134
 
133
135
  const missingEvents = requiredEvents.filter(evt => !windowConfig.events.includes(evt))
134
136
 
137
+ // entries on BOTH sides can be a name string or a {name, buffer|count|debounce|throttle}
138
+ // object — hosts declare how the event is conformed, plugins declare the exact
139
+ // configuration they expect, and matching is by name only
140
+ const entryName = entry => typeof entry === 'string' ? entry : entry?.name
141
+ const customEventNames = windowConfig.customEvents.map(entryName)
142
+ const missingCustomEvents = requiredCustomEvents.filter(evt => !customEventNames.includes(entryName(evt)))
143
+
135
144
  // If any are missing, log a single warning listing them
136
145
  if (missingEvents.length > 0) {
137
146
  console.warn(
138
147
  `plugin "${this.props.name}" requires the following config.WINDOW.events, --> ${missingEvents.join(', ')} <--`
139
148
  )
140
149
  }
150
+
151
+ if (missingCustomEvents.length > 0) {
152
+ // print the exact configuration entry so it can be pasted into the host config
153
+ const entryAsConfigStr = entry => typeof entry === 'string' ? `'${entry}'` : JSON.stringify(entry)
154
+ console.warn(
155
+ `plugin "${this.props.name}" requires the following config.WINDOW.customEvents, --> ${missingCustomEvents.map(entryAsConfigStr).join(', ')} <--`
156
+ )
157
+ }
141
158
  }
142
159
 
143
160
  defaultConfig() {
@@ -14,7 +14,7 @@ import { ChannelPayload } from './channels/channel-payload-class.js'
14
14
  import { ChannelPayloadFilter } from './utils/channel-payload-filter.js'
15
15
  import { SpynePlugin } from './spyne-plugins.js'
16
16
  import { deepMerge } from './utils/deep-merge.js'
17
- import { safeClone } from './utils/safe-clone.js'
17
+ import { safeClone, safeCloneDeep, safeAugment, safeFilter, safeMap, safeReject } from './utils/safe-clone.js'
18
18
  import { SpyneAppProperties } from './utils/spyne-app-properties.js'
19
19
  import { SpyneApp } from './spyne-app.js'
20
20
  import { setSpyneWarningsDisabled } from './utils/spyne-warn.js'
@@ -40,6 +40,11 @@ export {
40
40
  SpynePlugin,
41
41
  deepMerge,
42
42
  safeClone,
43
+ safeCloneDeep,
44
+ safeAugment,
45
+ safeFilter,
46
+ safeMap,
47
+ safeReject,
43
48
  sanitizeData,
44
49
  sanitizeEventTarget,
45
50
  allowCustomElements,
@@ -16,7 +16,6 @@ import {
16
16
  mergeAll,
17
17
  F,
18
18
  omit,
19
- flatten,
20
19
  any,
21
20
  curry,
22
21
  lte
@@ -218,8 +217,11 @@ export class ChannelPayloadFilter {
218
217
  const { payload, srcElement, event } = srcPayload || {}
219
218
 
220
219
  const reduceFindEl = (acc, src) => {
220
+ if (acc !== undefined) {
221
+ return acc
222
+ }
221
223
  const el = prop('el', src)
222
- if (ChannelPayloadFilter.elIsDomElement(el) && acc === undefined) {
224
+ if (ChannelPayloadFilter.elIsDomElement(el)) {
223
225
  acc = el
224
226
  }
225
227
  return acc
@@ -227,35 +229,37 @@ export class ChannelPayloadFilter {
227
229
 
228
230
  const el = [srcElement, payload, prop('srcElement', event), srcPayload].reduce(reduceFindEl, undefined)
229
231
 
230
- // RETURN BOOLEAN MATCH WITH PAYLOAD EL
231
- const compareEls = (elCompare) => elCompare.isEqualNode((el))
232
-
233
- // LOOP THROUGH NODES IN querySelectorAll()
234
- const mapNodeArrWithEl = (sel) => {
235
- // convert nodelist to array of els
236
- const nodeArr = Array.from(document.querySelectorAll(sel))
237
- // els array to boolean array
238
- return rMap(compareEls, nodeArr)
232
+ // CHECK IF PAYLOAD EL EXISTS
233
+ const elCanMatch = el !== null && typeof el === 'object' && typeof el.matches === 'function'
234
+
235
+ // the payload element is tested directly with Element.matches — the same
236
+ // "is this el one of the selector's elements" answer as the previous
237
+ // document-wide querySelectorAll walk, without scanning the document on
238
+ // every dispatch (isEqualNode could also pass a structural lookalike of
239
+ // a matched node, which was an over-match, not a contract)
240
+ const elMatches = (sel) => {
241
+ try {
242
+ return el.matches(sel)
243
+ } catch (e) {
244
+ if (SpyneAppProperties.debug === true) {
245
+ console.warn(`Spyne Warning: The ChannelPayloadFilter selector, ${sel}, is not a valid css selector!`)
246
+ }
247
+ return false
248
+ }
239
249
  }
240
250
 
251
+ const matchesBoolArr = elCanMatch ? rMap(elMatches, arr) : []
252
+
241
253
  if (debugLabel !== undefined) {
242
- const nodeArrResultsDebugger = compose(flatten, rMap(mapNodeArrWithEl))(arr)
243
254
  const selectorsArr = arr
244
- console.log(`%c CHANNEL PAYLOAD FILTER DEBUGGER ["${debugLabel}"] - selectors: `, 'color:orange;', { el, selectorsArr, nodeArrResultsDebugger })
245
- }
246
-
247
- // CHECK IF PAYLOAD EL EXISTS
248
- if (typeof (el) !== 'object') {
249
- return false
255
+ console.log(`%c CHANNEL PAYLOAD FILTER DEBUGGER ["${debugLabel}"] - selectors: `, 'color:orange;', { el, selectorsArr, nodeArrResultsDebugger: matchesBoolArr })
250
256
  }
251
257
 
252
- // LOOP THROUGH ALL SELECTORS IN MAIN ARRAY
253
- const nodeArrResult = compose(flatten, rMap(mapNodeArrWithEl))(arr)
254
- if (isEmpty(nodeArrResult) === true) {
258
+ if (elCanMatch === false || isEmpty(matchesBoolArr) === true) {
255
259
  return false
256
260
  }
257
261
 
258
- return any(equals(true), nodeArrResult)
262
+ return any(equals(true), matchesBoolArr)
259
263
  }
260
264
 
261
265
  static elIsDomElement(o) {
@@ -15,7 +15,7 @@ const getPropType = (prp) => {
15
15
  return type
16
16
  }
17
17
 
18
- const cmsDataReviveNestedProxyObj = (proxyObj, proxyReviverMethod) => {
18
+ const cmsDataReviveNestedProxyObj = (proxyObj = {}, proxyReviverMethod) => {
19
19
  const { __proxy__proxyName } = proxyObj
20
20
 
21
21
  if (__proxy__proxyName === undefined) {
@@ -50,7 +50,7 @@ const cmsDataReviveNestedProxyObj = (proxyObj, proxyReviverMethod) => {
50
50
  return rootObj
51
51
  }
52
52
 
53
- export const safeClone = function(o) {
53
+ const reviveIfProxy = (o = {}) => {
54
54
  const { __proxy__proxyName } = o
55
55
  if (__proxy__proxyName !== undefined) {
56
56
  const proxyReviverMethod = SpyneAppProperties.getProxyReviver(__proxy__proxyName)
@@ -58,6 +58,132 @@ export const safeClone = function(o) {
58
58
  return cmsDataReviveNestedProxyObj(o, proxyReviverMethod)
59
59
  }
60
60
  }
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ *
66
+ * Deep, proxy-preserving clone. Unlike safeClone — which only revives when the
67
+ * ROOT object is a proxy — this walks plain containers (objects and arrays)
68
+ * and revives any nested proxy it finds, so wrappers that hold proxied data
69
+ * (e.g. channel payloads, pageData containers) survive cloning intact.
70
+ * Functions are passed through by reference; cycles are guarded.
71
+ *
72
+ * @param {Object|Array} o
73
+ * @returns A writable copy in which every nested proxy has been revived
74
+ */
75
+ export const safeCloneDeep = function(o, seen = new WeakSet()) {
76
+ if (o === null || typeof o !== 'object') {
77
+ return o
78
+ }
79
+
80
+ const revived = reviveIfProxy(o)
81
+ if (revived !== undefined) {
82
+ return revived
83
+ }
84
+
85
+ if (seen.has(o)) {
86
+ return o
87
+ }
88
+ seen.add(o)
89
+
90
+ if (Array.isArray(o)) {
91
+ return o.map(item => safeCloneDeep(item, seen))
92
+ }
93
+
94
+ const out = {}
95
+ Object.entries(o).forEach(([key, val]) => {
96
+ out[key] = safeCloneDeep(val, seen)
97
+ })
98
+ return out
99
+ }
100
+
101
+ /**
102
+ * Re-wraps a derived array with the SOURCE array's proxy metadata via the
103
+ * registered reviver. Native filter/reject/map keep element identity but
104
+ * return a plain array — losing the array-level proxy that string-item
105
+ * template loops (e.g. {{#titles}}) rely on for __cms__dataId / keyFor
106
+ * lookups. In production (no proxies) this is a single undefined check.
107
+ *
108
+ * Note: the reviver rebuilds its key/value map from the derived contents, so
109
+ * value-keyed lookups survive while original index positions shift.
110
+ */
111
+ const rewrapArrayFromSource = (sourceArr, resultArr) => {
112
+ const proxyName = sourceArr?.__proxy__proxyName
113
+ if (proxyName === undefined) {
114
+ return resultArr
115
+ }
116
+ const proxyReviverMethod = SpyneAppProperties.getProxyReviver(proxyName)
117
+ if (typeof proxyReviverMethod !== 'function') {
118
+ return resultArr
119
+ }
120
+ return proxyReviverMethod(resultArr, sourceArr.__proxy__props)
121
+ }
122
+
123
+ /**
124
+ *
125
+ * Proxy-preserving Array.prototype.filter: elements keep their identity
126
+ * (native behavior) AND the returned array keeps the source array's proxy
127
+ * metadata. Use in place of ramda filter / arr.filter on CMS data.
128
+ */
129
+ export const safeFilter = function(arr, predicate) {
130
+ return rewrapArrayFromSource(arr, arr.filter(predicate))
131
+ }
132
+
133
+ /**
134
+ *
135
+ * Proxy-preserving reject — inverse of safeFilter.
136
+ */
137
+ export const safeReject = function(arr, predicate) {
138
+ return rewrapArrayFromSource(arr, arr.filter((item, i) => predicate(item, i) === false))
139
+ }
140
+
141
+ /**
142
+ *
143
+ * Proxy-preserving Array.prototype.map with an augment-in-place contract:
144
+ * proxied elements arrive in the callback as WRITABLE revived copies, so
145
+ * `safeMap(stories, s => { s.type = 'story'; return s; })` is safe by
146
+ * construction (no silent live-proxy write traps). Plain elements pass by
147
+ * reference exactly like native map, so production cost matches native.
148
+ * A callback that builds an entirely new object per element still strips
149
+ * that element's identity — there is nothing left to preserve.
150
+ */
151
+ export const safeMap = function(arr, mapFn) {
152
+ const result = arr.map((item, i) => {
153
+ const isProxied = item !== null && typeof item === 'object' && item.__proxy__proxyName !== undefined
154
+ return mapFn(isProxied ? safeCloneDeep(item) : item, i)
155
+ })
156
+ return rewrapArrayFromSource(arr, result)
157
+ }
158
+
159
+ /**
160
+ *
161
+ * Revive-then-augment. Live CMS proxies silently ignore writes of new keys,
162
+ * so properties cannot be attached to them directly. This returns a writable,
163
+ * proxy-preserved copy of o with the given props applied — the sanctioned way
164
+ * to add view or route properties to CMS-proxied data.
165
+ *
166
+ * @param {Object|Array} o
167
+ * @param {Object} propsObj properties to assign onto the revived copy
168
+ * @returns A writable proxy-preserved copy carrying the extra props
169
+ */
170
+ export const safeAugment = function(o, propsObj = {}) {
171
+ const target = safeCloneDeep(o)
172
+ Object.entries(propsObj).forEach(([key, val]) => {
173
+ target[key] = val
174
+ })
175
+ return target
176
+ }
177
+
178
+ export const safeClone = function(o, deep = false) {
179
+ const revived = reviveIfProxy(o)
180
+ if (revived !== undefined) {
181
+ return revived
182
+ }
183
+
184
+ if (deep === true) {
185
+ return safeCloneDeep(o)
186
+ }
61
187
 
62
188
  const isArr = is(Array)
63
189
  const isObj = is(Object)