spyne 0.24.0 → 0.25.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.24.0
2
+ * Spyne 0.25.0
3
3
  * https://spynejs.org
4
4
  *
5
5
  * @license
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  /*!
18
- * spynejs 0.24.0
18
+ * spynejs 0.25.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.24.0",
3
+ "version": "0.25.0",
4
4
  "description": "Reactive Real-DOM Framework for Advanced Javascript applications",
5
5
  "sideEffects": true,
6
6
  "main": "./lib/spyne.esm.js",
@@ -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.24.0'
9
+ const version = '0.25.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.24.0
44
+ * Spyne 0.25.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
+ customElements: [],
80
81
  baseHref: undefined,
81
82
  IMG_PATH: imgPath,
82
83
  pluginMethods:{
@@ -17,7 +17,8 @@ import { deepMerge } from './utils/deep-merge.js'
17
17
  import { safeClone } from './utils/safe-clone.js'
18
18
  import { SpyneAppProperties } from './utils/spyne-app-properties.js'
19
19
  import { SpyneApp } from './spyne-app.js'
20
- import sanitizeData, { sanitizeEventTarget } from './utils/sanitize-data.js'
20
+ import { setSpyneWarningsDisabled } from './utils/spyne-warn.js'
21
+ import sanitizeData, { sanitizeEventTarget, allowCustomElements } from './utils/sanitize-data.js'
21
22
 
22
23
  export {
23
24
  ViewStreamElement,
@@ -40,5 +41,7 @@ export {
40
41
  deepMerge,
41
42
  safeClone,
42
43
  sanitizeData,
43
- sanitizeEventTarget
44
+ sanitizeEventTarget,
45
+ allowCustomElements,
46
+ setSpyneWarningsDisabled
44
47
  }
@@ -2,6 +2,7 @@ import { from, of } from 'rxjs'
2
2
  import { catchError, map, mergeMap, share, tap } from 'rxjs/operators'
3
3
  import { compose, prop, defaultTo, pick, mergeDeepRight } from 'ramda'
4
4
  import sanitizeData from './sanitize-data.js'
5
+ import { spyneWarn } from './spyne-warn.js'
5
6
 
6
7
  export class ChannelFetchUtil {
7
8
  /**
@@ -236,7 +237,7 @@ export class ChannelFetchUtil {
236
237
  const url = prop('url', opts)
237
238
 
238
239
  if (url === undefined) {
239
- console.warn('SPYNE WARNING: URL is undefined for data channel')
240
+ spyneWarn('SPYNE WARNING: URL is undefined for data channel')
240
241
  }
241
242
 
242
243
  return url
@@ -292,7 +293,7 @@ export class ChannelFetchUtil {
292
293
  const method = opts?.method || 'GET'
293
294
 
294
295
  if (hasBody && method.toUpperCase() === 'GET') {
295
- console.warn(
296
+ spyneWarn(
296
297
  'SPYNE WARNING: Fetch body was provided with method GET. Changing method to POST.',
297
298
  opts
298
299
  )
@@ -1,5 +1,6 @@
1
1
  // src/utils/sanitize-data.js
2
2
  import DOMPurify from 'dompurify'
3
+ import { spyneWarn } from './spyne-warn.js'
3
4
  import { SpyneAppProperties } from './spyne-app-properties.js'
4
5
 
5
6
  let _sanitizeData
@@ -23,6 +24,56 @@ const normalizeUriForCheck = (val) => String(val).replace(/[\u0000-\u0020]/g, ''
23
24
 
24
25
  const isSafeUri = (val) => SAFE_URI_REGEXP.test(normalizeUriForCheck(val))
25
26
 
27
+ /* ---------------------------------------------
28
+ * Custom elements
29
+ *
30
+ * The framework's own custom elements (spyne-*) are first-party output —
31
+ * the CMS proxy wraps editable content in <spyne-cms-item> etc. — and are
32
+ * admitted in every mode. They are inert without their defining plugin
33
+ * and the attribute check below only admits data-* on them.
34
+ *
35
+ * Additional elements register via allowCustomElements() (additive only;
36
+ * names must contain a hyphen per the custom-element spec, so built-in
37
+ * tags like script or iframe can never be admitted through this path)
38
+ * or via config.customElements at SpyneApp.init.
39
+ * ------------------------------------------- */
40
+ const SPYNE_CUSTOM_ELEMENT_RE = /^spyne-[a-z][a-z0-9-]*$/i
41
+
42
+ const allowedCustomElementPatterns = []
43
+
44
+ const allowCustomElements = (elements = []) => {
45
+ const arr = Array.isArray(elements) ? elements : [elements]
46
+
47
+ arr.forEach((entry) => {
48
+ if (entry instanceof RegExp) {
49
+ allowedCustomElementPatterns.push(entry)
50
+ return
51
+ }
52
+
53
+ const name = String(entry).toLowerCase()
54
+ if (/^[a-z][a-z0-9]*-[a-z0-9-]+$/.test(name) !== true) {
55
+ spyneWarn(`SPYNE WARNING: "${entry}" is not a valid custom element name (a hyphen is required) and was not added to the sanitizer allowlist.`)
56
+ return
57
+ }
58
+
59
+ allowedCustomElementPatterns.push(name)
60
+ })
61
+ }
62
+
63
+ const customElementTagCheck = (tagName) => {
64
+ if (SPYNE_CUSTOM_ELEMENT_RE.test(tagName)) return true
65
+
66
+ return allowedCustomElementPatterns.some(p =>
67
+ p instanceof RegExp ? p.test(tagName) : p === tagName
68
+ )
69
+ }
70
+
71
+ const CUSTOM_ELEMENT_HANDLING = {
72
+ tagNameCheck: customElementTagCheck,
73
+ attributeNameCheck: /^data-[a-z0-9-]+$/i,
74
+ allowCustomizedBuiltInElements: false
75
+ }
76
+
26
77
  /* ---------------------------------------------
27
78
  * Mode configurations
28
79
  * ------------------------------------------- */
@@ -42,14 +93,16 @@ const SAFE_FOR_RICH_TEXT = {
42
93
  ALLOW_DATA_ATTR: true,
43
94
  FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
44
95
  FORBID_TAGS: ['script', 'object', 'embed', 'meta'],
45
- ALLOWED_URI_REGEXP: SAFE_URI_REGEXP
96
+ ALLOWED_URI_REGEXP: SAFE_URI_REGEXP,
97
+ CUSTOM_ELEMENT_HANDLING
46
98
  }
47
99
 
48
100
  const SAFE_FOR_APP = {
49
101
  FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'link', 'meta'],
50
102
  FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
51
103
  ALLOW_DATA_ATTR: false,
52
- ALLOWED_URI_REGEXP: SAFE_URI_REGEXP
104
+ ALLOWED_URI_REGEXP: SAFE_URI_REGEXP,
105
+ CUSTOM_ELEMENT_HANDLING
53
106
  }
54
107
 
55
108
  /* ---------------------------------------------
@@ -136,7 +189,7 @@ const addIframeHardeningHook = () => {
136
189
 
137
190
  const src = node.getAttribute('src') || ''
138
191
  if (src !== '' && isAllowedIframeSrc(src) !== true) {
139
- console.warn(`SPYNE WARNING: iframe src "${src}" was removed by the iframe policy (same-origin, https, or an allowed origin is required).`)
192
+ spyneWarn(`SPYNE WARNING: iframe src "${src}" was removed by the iframe policy (same-origin, https, or an allowed origin is required).`)
140
193
  node.removeAttribute('src')
141
194
  }
142
195
 
@@ -209,14 +262,18 @@ function makeSanitizeFn(mode = 'app') {
209
262
  * ------------------------------------------- */
210
263
  const sanitizeDataConfigure = (config = {}) => {
211
264
  if (isConfigured) {
212
- console.warn('sanitizeData is already configured. Reconfiguration is not allowed.')
265
+ spyneWarn('sanitizeData is already configured. Reconfiguration is not allowed.')
213
266
  return
214
267
  }
215
268
 
216
- const { strict = false, disableSanitize = false, iframes } = config
269
+ const { strict = false, disableSanitize = false, iframes, customElements } = config
217
270
 
218
271
  globalDisable = disableSanitize === true
219
272
 
273
+ if (customElements !== undefined) {
274
+ allowCustomElements(customElements)
275
+ }
276
+
220
277
  if (iframes && typeof iframes === 'object') {
221
278
  configuredIframePolicy = resolveIframePolicy(iframes)
222
279
  activeIframePolicy = configuredIframePolicy
@@ -376,7 +433,7 @@ const sanitizeEventTarget = (el, mode) => {
376
433
  * config.mode at SpyneApp.init. Retained for backward compatibility.
377
434
  * ------------------------------------------- */
378
435
  const setSanitizeDataForceStrict = (bool = true) => {
379
- console.warn('SPYNE DEPRECATION: setSanitizeDataForceStrict is deprecated. Pass { mode: "app" } to sanitizeData, or set config.mode in SpyneApp.init.')
436
+ spyneWarn('SPYNE DEPRECATION: setSanitizeDataForceStrict is deprecated. Pass { mode: "app" } to sanitizeData, or set config.mode in SpyneApp.init.')
380
437
  forceStrict = !!bool
381
438
  }
382
439
 
@@ -386,6 +443,8 @@ export {
386
443
  sanitizeDataForce,
387
444
  sanitizeAttribute,
388
445
  applyIframeHardening,
446
+ allowCustomElements,
447
+ CUSTOM_ELEMENT_HANDLING as customElementHandling,
389
448
  sanitizeEventTarget,
390
449
  setSanitizeDataForceStrict
391
450
  }
@@ -1,4 +1,6 @@
1
1
  import DOMPurify from 'dompurify'
2
+ import { spyneWarn } from './spyne-warn.js'
3
+ import { customElementHandling } from './sanitize-data.js'
2
4
 
3
5
  let _sanitizeHTML
4
6
  let isConfigured = false
@@ -17,7 +19,7 @@ let isConfigured = false
17
19
  */
18
20
  const sanitizeHTMLConfigure = (config = {}) => {
19
21
  if (isConfigured) {
20
- console.warn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
22
+ spyneWarn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
21
23
  return
22
24
  }
23
25
 
@@ -56,6 +58,10 @@ const sanitizeHTMLConfigure = (config = {}) => {
56
58
  ...(allowTargetAttr ? ['target'] : []),
57
59
  ...(allowIframe ? ['sandbox'] : [])
58
60
  ],
61
+ // Admits the framework's spyne-* elements (CMS proxy wrappers) and any
62
+ // elements registered via allowCustomElements/config.customElements.
63
+ // The tag check is a closure, so late plugin registration is honored.
64
+ CUSTOM_ELEMENT_HANDLING: customElementHandling,
59
65
  ...(allowIframe !== true && isRichtext ? { FORBID_TAGS: ['iframe'] } : {})
60
66
  }
61
67
 
@@ -110,7 +116,7 @@ const sanitizeHTMLConfigure = (config = {}) => {
110
116
  console.log('SPYNE: Trusted Types default policy registered. Note that browser enforcement additionally requires the CSP header: require-trusted-types-for \'script\'.')
111
117
  }
112
118
  } catch (err) {
113
- console.warn('SPYNE WARNING: A Trusted Types "default" policy already exists on this page. Falling back to direct sanitization.', err)
119
+ spyneWarn('SPYNE WARNING: A Trusted Types "default" policy already exists on this page. Falling back to direct sanitization.', err)
114
120
  }
115
121
  }
116
122
 
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Central outlet for SpyneJS developer-hint warnings (invalid selectors,
3
+ * sanitized attributes, policy removals, deprecations).
4
+ *
5
+ * Warnings are suppressed when window.SPYNE_SUPPRESS_WARNINGS is true —
6
+ * primarily for test runs, where expected-failure cases would otherwise
7
+ * flood the reporter. The flag lives on window (not module state) so it
8
+ * spans separately bundled test files.
9
+ *
10
+ * Security-posture warnings (e.g. config.disableSanitize) do NOT route
11
+ * through here and are always printed.
12
+ */
13
+ const spyneWarn = (...args) => {
14
+ const suppressed = typeof window !== 'undefined' && window.SPYNE_SUPPRESS_WARNINGS === true
15
+ if (suppressed !== true) {
16
+ console.warn(...args)
17
+ }
18
+ }
19
+
20
+ const setSpyneWarningsDisabled = (bool = true) => {
21
+ if (typeof window !== 'undefined') {
22
+ window.SPYNE_SUPPRESS_WARNINGS = bool === true
23
+ }
24
+ }
25
+
26
+ export { spyneWarn, setSpyneWarningsDisabled }
@@ -1,4 +1,5 @@
1
1
  import { baseCoreMixins } from '../utils/mixins/base-core-mixins.js'
2
+ import { spyneWarn } from '../utils/spyne-warn.js'
2
3
  import { DomElementTemplate } from './dom-element-template.js'
3
4
  import { deepMerge } from '../utils/deep-merge.js'
4
5
  import { sanitizeAttribute, applyIframeHardening } from '../utils/sanitize-data.js'
@@ -67,7 +68,7 @@ class DomElement {
67
68
  if (testMode !== true) {
68
69
  const { allowed, value } = sanitizeAttribute(key, val, el.tagName)
69
70
  if (allowed !== true) {
70
- console.warn(`SPYNE WARNING: The attribute "${key}" was removed during sanitization.`, el)
71
+ spyneWarn(`SPYNE WARNING: The attribute "${key}" was removed during sanitization.`, el)
71
72
  return
72
73
  }
73
74
  val = value
@@ -1,4 +1,5 @@
1
1
  import { head, compose, reject, split, isEmpty, lte, defaultTo, prop } from 'ramda'
2
+ import { spyneWarn } from '../utils/spyne-warn.js'
2
3
  import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
3
4
 
4
5
  function generateSpyneSelectorId(el) {
@@ -32,7 +33,7 @@ function testSelectors(cxt, sel, verboseBool) {
32
33
  const elIsDomElement = compose(lte(0), defaultTo(-1), prop('nodeType'))
33
34
 
34
35
  if (el !== null && elIsDomElement(el) === false) {
35
- console.warn(`Spyne Warning: the el object is not a valid single element, ${el}`)
36
+ spyneWarn(`Spyne Warning: the el object is not a valid single element, ${el}`)
36
37
  return
37
38
  }
38
39
 
@@ -48,7 +49,7 @@ function testSelectors(cxt, sel, verboseBool) {
48
49
 
49
50
  const isValidSelectorBool = isValidSelector(sel)
50
51
  if (verboseBool === true && isDevMode() === true && isValidSelectorBool === false) {
51
- console.warn(`Spyne Warning: the selector, ${sel} does not exist in this el, ${cxt}`)
52
+ spyneWarn(`Spyne Warning: the selector, ${sel} does not exist in this el, ${cxt}`)
52
53
  }
53
54
  }
54
55
  }
@@ -127,8 +128,6 @@ function ViewStreamSelector(cxt, sel) {
127
128
  */
128
129
  selector.forEach = (fn) => Array.from(getNodeListArray(cxt, sel)).map(fn)
129
130
 
130
- selector.getNodeListArray = () => getNodeListArray(cxt, sel)
131
-
132
131
  /**
133
132
  *
134
133
  * Adds the class to the Element or to the NodeList.
@@ -142,7 +141,7 @@ function ViewStreamSelector(cxt, sel) {
142
141
  const arr = getNodeListArray(cxt, sel)
143
142
  const addClass = item => item.classList.add(c)
144
143
  arr.forEach(addClass)
145
- return this
144
+ return selector
146
145
  }
147
146
 
148
147
  /**
@@ -156,7 +155,7 @@ function ViewStreamSelector(cxt, sel) {
156
155
  item.classList.remove(c)
157
156
  }
158
157
  arr.forEach(removeClass)
159
- return this
158
+ return selector
160
159
  }
161
160
 
162
161
  /**
@@ -181,7 +180,7 @@ function ViewStreamSelector(cxt, sel) {
181
180
  classStrArr.forEach(adder)
182
181
  }
183
182
  arr.forEach(setTheClass)
184
- return this
183
+ return selector
185
184
  }
186
185
 
187
186
  selector.unmount = () => {
@@ -205,23 +204,25 @@ function ViewStreamSelector(cxt, sel) {
205
204
  bool ? item.classList.add(c) : item.classList.remove(c)
206
205
  }
207
206
  arr.forEach(toggleClass)
208
- return this
207
+ return selector
209
208
  }
210
209
 
211
210
  selector.toggle = (c, bool) => {
212
211
  selector.toggleClass(c, bool)
213
- return this
212
+ return selector
214
213
  }
215
214
 
216
215
  /**
217
- * Attaches html to the Selector's element
216
+ * Attaches html to the Selector's element (the first match when the
217
+ * selector matches multiple elements).
218
218
  * @param htmlElement
219
219
  */
220
220
  selector.appendChild = (htmlElement) => {
221
- if (selector.el.length !== 0) {
222
- selector.el.appendChild(htmlElement)
221
+ const el = selector.el
222
+ if (el !== null) {
223
+ el.appendChild(htmlElement)
223
224
  } else {
224
- console.warn(`Spyne Warning: The selector, ${sel} does not appear to be valid!`)
225
+ spyneWarn(`Spyne Warning: The selector, ${sel} does not appear to be valid!`)
225
226
  }
226
227
 
227
228
  return selector.el
@@ -244,7 +245,7 @@ function ViewStreamSelector(cxt, sel) {
244
245
  arr.forEach(addClass)
245
246
  }
246
247
  requestAnimationFrame(delayAddClass)
247
- // window.setTimeout(delayAddClass, 1);
248
+ return selector
248
249
  }
249
250
 
250
251
  /**
@@ -253,19 +254,6 @@ function ViewStreamSelector(cxt, sel) {
253
254
  * @param {String|HTMLElement} elSel The selector for the element.
254
255
  * @desc Sets the class active HTMLElement from a NodeList.
255
256
  */
256
- selector.setActiveItem2 = (c, elSel) => {
257
- const arr = getNodeListArray(cxt, sel)
258
- const currentEl = typeof (elSel) === 'string' ? getElOrList(cxt, elSel) : elSel
259
- const toggleBool = item => item.isEqualNode(currentEl) ? item.classList.add(c) : item.classList.remove(c)
260
- if (isNodeElement(currentEl) === true) {
261
- arr.forEach(toggleBool)
262
- } else if (isDevMode() === true) {
263
- // console.log("SEL IS ",elSel,c);
264
- console.warn(`Spyne Warning: The selector, ${elSel}, does not appear to be a valid item in setActiveItem: ${c}`)
265
- }
266
- return this
267
- }
268
-
269
257
  selector.setActiveItem = (c, elSel) => {
270
258
  const arr = getNodeListArray(cxt, sel)
271
259
  const currentEl = typeof elSel === 'string'
@@ -280,17 +268,17 @@ function ViewStreamSelector(cxt, sel) {
280
268
  if (matchingEl) {
281
269
  matchingEl.classList.add(c)
282
270
  } else if (isDevMode() === true) {
283
- console.warn(
271
+ spyneWarn(
284
272
  `Spyne Warning: The selector, ${elSel}, is valid but does not match any item in setActiveItem: ${c}`
285
273
  )
286
274
  }
287
275
  } else if (isDevMode() === true) {
288
- console.warn(
276
+ spyneWarn(
289
277
  `Spyne Warning: The selector, ${elSel}, does not appear to be a valid item in setActiveItem: ${c}`
290
278
  )
291
279
  }
292
280
 
293
- return this
281
+ return selector
294
282
  }
295
283
 
296
284
  /**
@@ -298,10 +286,24 @@ function ViewStreamSelector(cxt, sel) {
298
286
  * @function el
299
287
  *
300
288
  * @desc
301
- * getter for the selector
289
+ * getter that always resolves to a single element: the matched element,
290
+ * or the first item when the selector matches multiple elements (a
291
+ * warning is logged in debug mode).
302
292
  *
303
293
  * @returns
304
- * The a single element or a NodeList from the selector
294
+ * An HTMLElement, or null when the selector matches nothing
295
+ * (mirroring querySelector)
296
+ */
297
+
298
+ /**
299
+ *
300
+ * @function els
301
+ *
302
+ * @desc
303
+ * getter that always resolves to an Array of the matched elements.
304
+ *
305
+ * @returns
306
+ * An Array of HTMLElements; empty when the selector matches nothing
305
307
  */
306
308
 
307
309
  /**
@@ -312,7 +314,7 @@ function ViewStreamSelector(cxt, sel) {
312
314
  * Determines the length of the NodeList
313
315
  *
314
316
  * @returns
315
- * The length of the selector as a NodeList
317
+ * The number of elements matched by the selector
316
318
  */
317
319
 
318
320
  /**
@@ -326,24 +328,27 @@ function ViewStreamSelector(cxt, sel) {
326
328
  * Boolean
327
329
  */
328
330
 
329
- Object.defineProperty(selector, 'el', { get: () => getElOrList(cxt, sel, true) })
330
- Object.defineProperty(selector, 'els', { get: () => getNodeListArray(cxt, sel) })
331
- Object.defineProperty(selector, 'len', { get: () => getNodeListArray(cxt, sel, false).length })
332
- Object.defineProperty(selector, 'exists', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
333
- Object.defineProperty(selector, 'exist', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
334
- Object.defineProperty(selector, 'nodeList', { get: () => getNodeListArray(cxt, sel) })
335
- Object.defineProperty(selector, 'arr', {
331
+ const getElsArray = (verboseBool = true) => Array.from(getNodeListArray(cxt, sel, verboseBool))
332
+
333
+ Object.defineProperty(selector, 'el', {
336
334
  get: () => {
337
- const el = getElOrList(cxt, sel, true)
338
- if (el === undefined) {
339
- return []
340
- } else if (el.length === undefined) {
341
- return [el]
342
- } else {
343
- return Array.from(el)
335
+ const list = getElsArray()
336
+ if (list.length > 1 && isDevMode() === true) {
337
+ spyneWarn(`Spyne Warning: el$("${sel !== undefined ? sel : cxt}").el matched ${list.length} elements; returning the first. Use .els for the full list.`)
344
338
  }
339
+ return list.length >= 1 ? list[0] : null
345
340
  }
346
341
  })
342
+ Object.defineProperty(selector, 'els', { get: () => getElsArray() })
343
+ Object.defineProperty(selector, 'arr', { get: () => getElsArray() })
344
+
345
+ // Undocumented. Retains the pre-0.24 polymorphic el contract — a single
346
+ // element, a NodeList, or an empty NodeList depending on match count —
347
+ // to assist in debugging and migrating older SpyneJS apps.
348
+ Object.defineProperty(selector, 'elLegacy', { get: () => getElOrList(cxt, sel, true) })
349
+ Object.defineProperty(selector, 'len', { get: () => getNodeListArray(cxt, sel, false).length })
350
+ Object.defineProperty(selector, 'length', { get: () => getNodeListArray(cxt, sel, false).length })
351
+ Object.defineProperty(selector, 'exists', { get: () => getNodeListArray(cxt, sel, false).length >= 1 })
347
352
 
348
353
  Object.defineProperty(selector, 'inline', { set: (val) => setInlineCss(val, cxt, sel), get: () => selector })
349
354
  Object.defineProperty(selector, 'inlineCss', { set: (val) => setInlineCss(val, cxt, sel), get: () => selector })
@@ -1,3 +1,8 @@
1
+ // Silence SpyneJS developer-hint warnings across all test bundles.
2
+ // Expected-failure cases (invalid selectors, blocked URIs, policy removals)
3
+ // would otherwise flood the karma reporter. Security warnings still print.
4
+ window.SPYNE_SUPPRESS_WARNINGS = true
5
+
1
6
  import { SpyneApp } from '../spyne/spyne'
2
7
 
3
8
  // import {AppView} from "../app/app-view";
@@ -1,6 +1,6 @@
1
1
  const { expect } = require('chai')
2
2
  import { SpyneApp, SpyneAppProperties } from '../../spyne/spyne'
3
- import sanitizeData, { sanitizeAttribute } from '../../spyne/utils/sanitize-data'
3
+ import sanitizeData, { sanitizeAttribute, allowCustomElements } from '../../spyne/utils/sanitize-data'
4
4
  import sanitizeHTML from '../../spyne/utils/sanitize-html'
5
5
  import { DomElement } from '../../spyne/views/dom-element'
6
6
  import { DomElementTemplate } from '../../spyne/views/dom-element-template'
@@ -268,6 +268,70 @@ describe('sanitization security', () => {
268
268
  })
269
269
  })
270
270
 
271
+ describe('CMS wrappers and custom elements', () => {
272
+ const cmsMarkup = '<spyne-cms-item data-cms-id="post.title" data-cms-key="title">' +
273
+ '<spyne-cms-item-hitbox></spyne-cms-item-hitbox>' +
274
+ '<spyne-cms-item-text>Hello</spyne-cms-item-text>' +
275
+ '</spyne-cms-item>'
276
+
277
+ it('should keep spyne-* CMS wrappers and data-cms attributes in app mode', () => {
278
+ const clean = sanitizeData(cmsMarkup, { mode: 'app' })
279
+
280
+ expect(clean).to.include('<spyne-cms-item')
281
+ expect(clean).to.include('data-cms-key="title"')
282
+ expect(clean).to.include('<spyne-cms-item-hitbox')
283
+ expect(clean).to.include('<spyne-cms-item-text>Hello</spyne-cms-item-text>')
284
+ })
285
+
286
+ it('should keep spyne-* CMS wrappers in richtext mode', () => {
287
+ const clean = sanitizeData(cmsMarkup, { mode: 'richtext' })
288
+
289
+ expect(clean).to.include('<spyne-cms-item')
290
+ expect(clean).to.include('data-cms-id="post.title"')
291
+ })
292
+
293
+ it('should keep spyne-* CMS wrappers at the template layer', () => {
294
+ const clean = String(sanitizeHTML(cmsMarkup))
295
+
296
+ expect(clean).to.include('<spyne-cms-item')
297
+ expect(clean).to.include('<spyne-cms-item-text>Hello</spyne-cms-item-text>')
298
+ })
299
+
300
+ it('should still sanitize content inside CMS wrappers', () => {
301
+ const dirty = '<spyne-cms-item data-cms-key="body">' +
302
+ '<spyne-cms-item-text><script>bad()</script><b>ok</b></spyne-cms-item-text>' +
303
+ '</spyne-cms-item>'
304
+ const clean = sanitizeData(dirty, { mode: 'app' })
305
+
306
+ expect(clean).to.include('<spyne-cms-item')
307
+ expect(clean).to.not.include('<script')
308
+ expect(clean).to.include('<b>ok</b>')
309
+ })
310
+
311
+ it('should not allow event-handler attributes on CMS wrappers', () => {
312
+ const dirty = '<spyne-cms-item onclick="evil()" data-cms-key="x">y</spyne-cms-item>'
313
+ const clean = sanitizeData(dirty, { mode: 'app' })
314
+
315
+ expect(clean).to.include('<spyne-cms-item')
316
+ expect(clean).to.not.include('onclick')
317
+ })
318
+
319
+ it('should admit elements registered via allowCustomElements', () => {
320
+ allowCustomElements(['my-widget'])
321
+ const clean = sanitizeData('<my-widget data-config="a">w</my-widget>', { mode: 'app' })
322
+
323
+ expect(clean).to.include('<my-widget')
324
+ expect(clean).to.include('data-config="a"')
325
+ })
326
+
327
+ it('should reject invalid custom element names', () => {
328
+ allowCustomElements(['script', 'iframe', 'div'])
329
+ const clean = sanitizeData('<script>bad()</script>', { mode: 'app' })
330
+
331
+ expect(clean).to.not.include('<script')
332
+ })
333
+ })
334
+
271
335
  describe('DomElementTemplate output', () => {
272
336
  it('should sanitize renderToString output', () => {
273
337
  const tmpl = new DomElementTemplate('<b>{{word}}</b><script>bad()</script>', { word: 'hello' })
@@ -41,14 +41,14 @@ describe('Dom Item Selector', () => {
41
41
  const el = document.querySelector('ul#my-list')
42
42
  const el$ = ViewStreamSelector('ul#my-list')
43
43
  const liList = el$('li')
44
- expect(liList.el.length).to.eq(5)
44
+ expect(liList.els.length).to.eq(5)
45
45
  })
46
46
  it('should add class to li', () => {
47
47
  const el = document.querySelector('ul#my-list')
48
48
  const el$ = ViewStreamSelector('ul#my-list')
49
49
  const liList = el$('li')
50
50
  liList.addClass('foo')
51
- const hasFooClassBool = liList.el[0].classList.contains('foo')
51
+ const hasFooClassBool = liList.els[0].classList.contains('foo')
52
52
  expect(hasFooClassBool).to.eq(true)
53
53
  })
54
54
 
@@ -57,7 +57,7 @@ describe('Dom Item Selector', () => {
57
57
  const el$ = ViewStreamSelector('ul#my-list')
58
58
  const liList = el$('li')
59
59
  liList.removeClass('has-svg')
60
- const hasSvgClassBool = liList.el[0].classList.contains('has-svg')
60
+ const hasSvgClassBool = liList.els[0].classList.contains('has-svg')
61
61
  expect(hasSvgClassBool).to.eq(false)
62
62
  })
63
63
 
@@ -66,8 +66,8 @@ describe('Dom Item Selector', () => {
66
66
  const el$ = ViewStreamSelector('ul#my-list')
67
67
  const liList = el$('li')
68
68
  liList.setClass('foo bar')
69
- // console.log('liList ',liList.el[0].className)
70
- const isFooBarClassBool = liList.el[0].className === 'foo bar'
69
+ // console.log('liList ',liList.els[0].className)
70
+ const isFooBarClassBool = liList.els[0].className === 'foo bar'
71
71
  expect(isFooBarClassBool).to.eq(true)
72
72
  })
73
73
 
@@ -76,7 +76,7 @@ describe('Dom Item Selector', () => {
76
76
  const el$ = ViewStreamSelector('ul#my-list')
77
77
  const liList = el$('li')
78
78
  liList.inlineCss = 'background:orange;'
79
- const backgroundSetBool = liList.el[0].style.getPropertyValue('background') === 'orange'
79
+ const backgroundSetBool = liList.els[0].style.getPropertyValue('background') === 'orange'
80
80
  expect(backgroundSetBool).to.eq(true)
81
81
  })
82
82
 
@@ -85,7 +85,7 @@ describe('Dom Item Selector', () => {
85
85
  const el$ = ViewStreamSelector('ul#my-list')
86
86
  const liList = el$('li')
87
87
  liList.toggleClass('foo', true)
88
- const hasFooClassBool = liList.el[0].classList.contains('foo')
88
+ const hasFooClassBool = liList.els[0].classList.contains('foo')
89
89
  expect(hasFooClassBool).to.eq(true)
90
90
  })
91
91