spyne 0.21.2 → 0.22.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.
@@ -2,15 +2,59 @@ import { includes, __, ifElse, path, prop, reject, is, isNil, isEmpty } from 'ra
2
2
  import sanitizeHTML from '../utils/sanitize-html.js'
3
3
  import { SpyneAppProperties } from '../utils/spyne-app-properties.js'
4
4
 
5
+ /**
6
+ * DomElementTemplate
7
+ *
8
+ * Mustache-compatible template engine used by ViewStream and DomElement for HTML rendering.
9
+ *
10
+ * Supported syntax:
11
+ * {{key}} variable interpolation, HTML-escaped
12
+ * {{key.path.to.value}} dot-notation property access
13
+ * {{#key}}...{{/key}} array/object sections
14
+ * {{.}} current element inside a loop
15
+ * {{#}}...{{/}} top-level bare array (passed as `data`)
16
+ *
17
+ * Nesting:
18
+ * DomElementTemplate supports exactly ONE level of nested array sections.
19
+ * A template like {{#rows}}...{{#cells}}...{{/cells}}...{{/rows}} is supported.
20
+ * Deeper nesting — {{#a}}{{#b}}{{#c}}...{{/c}}{{/b}}{{/a}} — is NOT supported.
21
+ *
22
+ * When a template has deeper nesting, a warning is logged in debug mode and
23
+ * the third-level loop is not processed. This is a deliberate design choice:
24
+ * deep hierarchical rendering belongs in nested ViewStreams, not in templates.
25
+ * Nested ViewStreams provide proper lifecycle semantics, isolated rendering,
26
+ * and clearer architectural boundaries than arbitrarily-nested templates.
27
+ *
28
+ * Not currently supported in nested loops:
29
+ * - CMS proxy metadata (__cms__dataId, __cms__keyFor_*). Inner-loop CMS
30
+ * editing is a future enhancement; until then, content inside an inner
31
+ * loop is rendered as read-only from the CMS perspective.
32
+ */
5
33
  export class DomElementTemplate {
6
34
  constructor(template, data = {}, opts = {}) {
7
35
  this.template = this.formatTemplate(template)
8
36
  this.isProxyData = data.__cms__isProxy === true
9
37
  this.testMode = opts?.testMode
10
38
 
39
+ // Normalize triple-bracket tags to double-bracket.
40
+ //
41
+ // DomElementTemplate treats {{{key}}} as an alias for {{key}} — both
42
+ // interpolate the value as-is. Mustache's convention of "triple-bracket
43
+ // means unescaped" doesn't apply here because SpyneJS handles escaping
44
+ // and sanitization through `sanitize-data` (keyed on SpyneApp mode)
45
+ // before values reach the template, not through syntax-level escaping.
46
+ //
47
+ // We accept triple-bracket syntax so that authors and AI familiar with
48
+ // standard Mustache don't have their templates silently break when they
49
+ // reach for {{{key}}} out of habit. The normalization happens once here,
50
+ // so the rest of the engine only ever reasons about double-bracket tags.
51
+ this.template = DomElementTemplate.normalizeTripleBrackets(this.template)
52
+
11
53
  if (this.isProxyData === true) {
12
54
  if (SpyneAppProperties.enableCMSProxies === true) {
13
55
  this.template = SpyneAppProperties.formatTemplateForProxyData(this.template)
56
+ } else {
57
+ this.template = DomElementTemplate.formatTemplateForProxyData(this.template)
14
58
  }
15
59
  }
16
60
 
@@ -26,6 +70,13 @@ export class DomElementTemplate {
26
70
 
27
71
  this.templateData = data
28
72
 
73
+ // Stash used by per-iteration substitution so that CMS proxy lookups
74
+ // for string-array items (where __cms__dataId / __cms__keyFor_* live on
75
+ // the PARENT array, not on the individual string elements) can find the
76
+ // parent object at render time. Set by parseTheTmplLoop, cleared when
77
+ // that loop completes.
78
+ this.currentOuterLoopData = null
79
+
29
80
  const strArr = DomElementTemplate.getStringArray(this.template)
30
81
 
31
82
  let strMatches = this.template.match(DomElementTemplate.findTmplLoopsRE())
@@ -33,9 +84,9 @@ export class DomElementTemplate {
33
84
 
34
85
  const parseTmplLoopsRE = DomElementTemplate.parseTmplLoopsRE()
35
86
 
36
- const parseTmplLoopFn = this.parseTheTmplLoop.bind(this)
87
+ const parseTmplLoopFn = this.parseTheTmplLoop.bind(this)
37
88
 
38
- const mapTmplLoop = (str, data) => {
89
+ const mapTmplLoop = (str) => {
39
90
  return str.replace(parseTmplLoopsRE, parseTmplLoopFn)
40
91
  }
41
92
 
@@ -52,6 +103,37 @@ export class DomElementTemplate {
52
103
  return /({{\.\*?}})/.test(str)
53
104
  }
54
105
 
106
+ /**
107
+ * Normalizes triple-bracket tags to double-bracket form.
108
+ *
109
+ * {{{key}}} → {{key}}
110
+ * {{{a.b.c}}} → {{a.b.c}}
111
+ * {{{.}}} → {{.}}
112
+ * {{{.*}}} → {{.*}}
113
+ *
114
+ * Section markers {{#key}} and {{/key}} are already two-bracket by
115
+ * convention and are not affected. Tags of the wrong shape (unbalanced,
116
+ * four or more brackets, etc.) are left untouched — anything the main
117
+ * parser doesn't recognize later will render as literal text, which is
118
+ * the correct failure mode for a logic-less template engine.
119
+ *
120
+ * This exists because DomElementTemplate treats {{{key}}} and {{key}}
121
+ * as interchangeable — sanitization and escaping are config-driven
122
+ * through `sanitize-data`, not syntax-driven through bracket count.
123
+ * Normalizing at construction time means the rest of the engine (loop
124
+ * regexes, CMS proxy wrapping, variable substitution) only reasons
125
+ * about one bracket style.
126
+ */
127
+ static normalizeTripleBrackets(template) {
128
+ if (typeof template !== 'string' || template.indexOf('{{{') === -1) {
129
+ return template
130
+ }
131
+ // Match balanced triple-brackets containing anything that isn't a
132
+ // brace character. Keeps the captured inner content, strips the
133
+ // outer third brace on each side.
134
+ return template.replace(/\{\{\{([^{}]+?)\}\}\}/g, '{{$1}}')
135
+ }
136
+
55
137
  // FIND CORRECT NESTED DATA
56
138
  static getNestedDataReducer(data = {}, param = '') {
57
139
  const dataReducer = (nestedData, str) => {
@@ -67,24 +149,63 @@ export class DomElementTemplate {
67
149
  static getStringArray(template) {
68
150
  const strArr = template.split(DomElementTemplate.findTmplLoopsRE())
69
151
  const emptyRE = /^([\\n\s\W]+)$/
152
+ // findTmplLoopsRE has TWO capture groups (the full loop match, and the
153
+ // \2-backreferenced key name). When used with String.prototype.split, the
154
+ // second group is emitted into the result array as a leaked chunk.
155
+ //
156
+ // The chunks arrive in a predictable 3-step cycle:
157
+ // index 0: pre-text before the first loop
158
+ // index 1: full match the loop (keep)
159
+ // index 2: group-2 leak the loop's key name (discard)
160
+ // index 3: between-loops text
161
+ // index 4: full match
162
+ // index 5: group-2 leak
163
+ // ...
164
+ // So any index i where i >= 2 and (i - 2) % 3 === 0 is a key-name leak
165
+ // that should not flow into the render pipeline.
166
+ const withoutLeaks = strArr.filter((_, i) => !(i >= 2 && (i - 2) % 3 === 0))
70
167
  const filterOutEmptyStrings = s => s.match(emptyRE)
71
- const finalStr = reject(filterOutEmptyStrings, strArr)
168
+ const finalStr = reject(filterOutEmptyStrings, withoutLeaks)
72
169
 
73
170
  return finalStr
74
171
  }
75
172
 
173
+ /**
174
+ * Regex that finds top-level loop sections, pairing each opening tag with a
175
+ * closing tag of the SAME NAME via the \2 backreference. This is what makes
176
+ * {{#rows}}<tr>{{#cells}}<td>{{.}}</td>{{/cells}}</tr>{{/rows}} match as a
177
+ * single outer loop rather than getting truncated at the inner {{/cells}}.
178
+ */
76
179
  static findTmplLoopsRE() {
77
- return /({{#[\w.]+}}[\w\n\s\W]+?{{\/[\w.]+}})/gm
180
+ return /({{#([\w.]+)}}[\w\n\s\W]+?{{\/\2}})/gm
78
181
  }
79
182
 
80
183
  static parseTmplLoopsRE() {
81
184
  return /({{#([\w.]+)}})([\w\n\s\W]+?)({{\/\2}})/gm
82
185
  }
83
186
 
187
+ /**
188
+ * Regex for finding a single inner loop inside an outer loop's body.
189
+ * Identical in form to parseTmplLoopsRE, but created fresh to avoid
190
+ * shared-lastIndex bugs with global regexes when used in .replace callbacks.
191
+ */
192
+ static innerLoopRE() {
193
+ return /({{#([\w.]+)}})([\w\n\s\W]+?)({{\/\2}})/gm
194
+ }
195
+
84
196
  static swapParamsForTagsRE() {
85
197
  return /({{)(.*?)(}})/gm
86
198
  }
87
199
 
200
+ /**
201
+ * Detects whether a template body contains any {{#key}}...{{/key}} pair.
202
+ * Used to decide whether the inner-loop pass is needed, and to detect
203
+ * two-level nesting for warning purposes.
204
+ */
205
+ static hasLoop(str) {
206
+ return DomElementTemplate.innerLoopRE().test(str)
207
+ }
208
+
88
209
  removeThis() {
89
210
  if (this !== undefined) {
90
211
  this.finalArr = undefined
@@ -94,16 +215,14 @@ export class DomElementTemplate {
94
215
  }
95
216
 
96
217
  /**
97
- *
98
218
  * @desc Returns a document fragment generated from the template and any added data.
99
219
  */
100
-
101
220
  renderDocFrag() {
102
221
  let html = DomElementTemplate.replaceImgPath(this.finalArr.join(''))
103
222
  if (this.testMode !== true) {
104
223
  html = sanitizeHTML(html)
105
224
  }
106
- const isTableSubTag = /^([^>]*?)(<){1}(\b)(thead|col|colgroup|tbody|td|tfoot|tr|th)(\b)([^\0]*)$/.test(html)
225
+ const isTableSubTag = /^([^>]*?)(<){1}(\b)(thead|col|colgroup|tbody|td|tfoot|tr|th)(\b)([^\0]*)$/.test(html)
107
226
  const el = isTableSubTag ? html : document.createRange().createContextualFragment(html)
108
227
 
109
228
  window.setTimeout(this.removeThis, 2)
@@ -147,76 +266,211 @@ export class DomElementTemplate {
147
266
  return templateStr
148
267
  }
149
268
 
150
- parseTheTmplLoop(str, p1, p2, p3) {
151
- const reDot = /(\.)/gm
152
- const subStr = p3
153
- let elData = DomElementTemplate.getNestedDataReducer(this.templateData, p2)
269
+ /**
270
+ * Resolves a single inner loop (one level down) inside an outer-loop body,
271
+ * using `parentItem` as the data context. If the inner body itself contains
272
+ * another loop, a debug-mode warning is logged and that deeper loop is not
273
+ * processed.
274
+ *
275
+ * Returns the body string with any inner loop expanded, ready for
276
+ * outer-level variable substitution.
277
+ */
278
+ processInnerLoop(bodyStr, parentItem) {
279
+ const innerRE = DomElementTemplate.innerLoopRE()
280
+
281
+ return bodyStr.replace(innerRE, (fullMatch, openTag, innerKey, innerBody /*, closeTag */) => {
282
+ // Detect disallowed second-level nesting inside the inner body.
283
+ if (DomElementTemplate.hasLoop(innerBody) === true) {
284
+ if (SpyneAppProperties.debug === true && this.testMode !== true) {
285
+ console.warn(
286
+ 'Spyne Warning: DomElementTemplate supports one level of nested array loops. ' +
287
+ `A deeper nested loop was detected inside "{{#${innerKey}}}". ` +
288
+ 'Consider restructuring via nested ViewStreams instead.'
289
+ )
290
+ }
291
+ // Strip unsupported deeper-level section tags so they don't emit as
292
+ // garbled variables during substitution below.
293
+ innerBody = innerBody.replace(/{{[#/][\w.]+}}/g, '')
294
+ }
154
295
 
155
- const arrayStringToObjAdapter = (d, str, i) => {
156
- if (DomElementTemplate.isPrimitiveTag(str)) {
157
- return parseString(d, str, i)
296
+ const innerData = DomElementTemplate.getNestedDataReducer(parentItem, innerKey)
297
+
298
+ if (isNil(innerData) === true || isEmpty(innerData)) {
299
+ return ''
158
300
  }
159
301
 
160
- const createDataObj = () => {
161
- const spyneLoopKey = d
162
- const loopIndex = i
163
- const loopNum = i + 1
302
+ const innerArr = Array.isArray(innerData) ? innerData : [innerData]
164
303
 
165
- if (this.isProxyData) {
166
- const __cms__dataId = elData.__cms__dataId
167
- const keyIdStr = `__cms__keyFor_${d}`
168
- const origKey = elData[keyIdStr]
169
- return { spyneLoopKey, __cms__dataId, origKey, loopIndex, loopNum, d }
170
- }
171
- return { spyneLoopKey, loopIndex, loopNum }
304
+ // While processing an inner loop, the "outer" data for CMS string-item
305
+ // lookups is the current innerData (the array/object the inner items
306
+ // belong to), not the original outer-loop array. Swap it in, restore
307
+ // when the inner pass completes.
308
+ const previousOuter = this.currentOuterLoopData
309
+ this.currentOuterLoopData = innerData
310
+
311
+ const rendered = innerArr.map((innerItem, innerIdx) => {
312
+ return this.substituteForItem(innerBody, innerItem, innerIdx)
313
+ }).join('')
314
+
315
+ this.currentOuterLoopData = previousOuter
316
+ return rendered
317
+ })
318
+ }
319
+
320
+ /**
321
+ * Substitutes {{...}} variables in `str` using `item` as the data context.
322
+ *
323
+ * Three cases:
324
+ *
325
+ * 1. String item + primitive {{.}}/{{.*}} template:
326
+ * direct text substitution (no CMS wrapping possible — this path only
327
+ * runs when formatTemplateForProxyData has NOT wrapped the tag).
328
+ *
329
+ * 2. String item + non-primitive template:
330
+ * can happen two ways: (a) a section iterating strings where
331
+ * formatTemplateForProxyData has replaced {{.}}/{{.*}} with
332
+ * {{spyneLoopKey}} + {{origKey}} inside a <spyne-cms-item> wrapper,
333
+ * or (b) a non-proxy template with positional tokens only.
334
+ * Build a context that satisfies both — spyneLoopKey = the string,
335
+ * plus __cms__dataId / origKey looked up on the parent array when
336
+ * proxy data is active.
337
+ *
338
+ * 3. Object item:
339
+ * the conventional path — build a lookup object with the item's
340
+ * properties plus positional tokens, substitute by property name
341
+ * or dot-path.
342
+ */
343
+ substituteForItem(str, item, index) {
344
+ // Case 1: string item with a primitive-tag template.
345
+ if (typeof item === 'string') {
346
+ if (DomElementTemplate.isPrimitiveTag(str)) {
347
+ const escaped = item.replace(/\$/g, '$$$$') // $ -> $$ for replace() literal safety
348
+ return str.replace(DomElementTemplate.swapParamsForTagsRE(), escaped)
172
349
  }
173
350
 
174
- return parseObject(createDataObj(), str, i)
351
+ // Case 2: string item with a non-primitive template.
352
+ const ctx = this.buildStringItemContext(item, index)
353
+ return this.substituteObject(str, ctx)
175
354
  }
176
355
 
177
- const parseString = (item, str, index, origIndex) => {
178
- item = item.replace(/\$/g, '$$$$') // $ → $$
179
- return str.replace(DomElementTemplate.swapParamsForTagsRE(), item)
356
+ // Case 3: object item.
357
+ const dataObj = this.buildItemContext(item, index)
358
+ return this.substituteObject(str, dataObj)
359
+ }
360
+
361
+ /**
362
+ * Builds the per-iteration lookup context for a STRING item.
363
+ *
364
+ * Always includes:
365
+ * - spyneLoopKey: the string itself (matches the CMS proxy wrapping,
366
+ * which replaces {{.}}/{{.*}} inside string loops with {{spyneLoopKey}})
367
+ * - loopIndex / loopNum: positional tokens
368
+ *
369
+ * When proxy data is active, additionally includes:
370
+ * - __cms__dataId: the CMS identifier for the PARENT array (the string
371
+ * items themselves are primitives and don't carry the id)
372
+ * - origKey: the original key the CMS uses to address this specific
373
+ * string within its parent, looked up via __cms__keyFor_<item>
374
+ */
375
+ buildStringItemContext(item, index) {
376
+ const context = {
377
+ spyneLoopKey: item,
378
+ loopIndex: index,
379
+ loopNum: index + 1
180
380
  }
181
381
 
182
- // PARSING ARRAYS AND OBJECTS
183
- const parseObject = (obj, str, i) => {
184
- /// LOOP NUMBER VALUES AUTO ADDED
382
+ if (this.isProxyData === true) {
383
+ const outerData = this.currentOuterLoopData
384
+ if (outerData && typeof outerData === 'object') {
385
+ context.__cms__dataId = outerData.__cms__dataId || ''
386
+ const keyIdStr = `__cms__keyFor_${item}`
387
+ context.origKey = outerData[keyIdStr] !== undefined ? outerData[keyIdStr] : ''
388
+ } else {
389
+ context.__cms__dataId = ''
390
+ context.origKey = ''
391
+ }
392
+ }
185
393
 
186
- // const loopIndex = i;
187
- // const loopNum = i+1;
394
+ return context
395
+ }
188
396
 
189
- const loopObj = (str, p1, p2) => {
190
- // DOT SYNTAX CHECK
191
- const hash = {
192
- loopIndex: i,
193
- loopNum: i + 1
194
- }
397
+ /**
398
+ * Builds the per-iteration lookup context for an OBJECT item, including
399
+ * CMS-proxy metadata when this template is rendering proxied data.
400
+ */
401
+ buildItemContext(item, index) {
402
+ const context = Object.assign({}, item, {
403
+ loopIndex: index,
404
+ loopNum: index + 1
405
+ })
195
406
 
196
- // IF {{.}}
197
- if (reDot.test(p2) === false && obj[p2] !== undefined) {
198
- return hash[p2] !== undefined ? hash[p2] : obj[p2]
199
- }
407
+ if (this.isProxyData === true) {
408
+ context.__cms__dataId = item.__cms__dataId
409
+ context.spyneLoopKey = item
410
+ context.loopIndex = index
411
+ context.loopNum = index + 1
412
+ }
413
+
414
+ return context
415
+ }
416
+
417
+ /**
418
+ * Replaces {{key}} and {{a.b.c}} tokens in `str` using `ctx` as the source.
419
+ */
420
+ substituteObject(str, ctx) {
421
+ const reDot = /(\.)/gm
422
+ const positional = { loopIndex: ctx.loopIndex, loopNum: ctx.loopNum }
200
423
 
201
- const dataReducedVal = this.getDataValFromPathStr(p2, obj)
202
- return hash[p2] !== undefined ? hash[p2] : dataReducedVal
424
+ const replacer = (match, p1, p2) => {
425
+ // Auto-injected positional tokens take precedence
426
+ if (positional[p2] !== undefined) return positional[p2]
427
+
428
+ // Simple key access
429
+ if (reDot.test(p2) === false && ctx[p2] !== undefined) {
430
+ return ctx[p2]
203
431
  }
204
- return str.replace(DomElementTemplate.swapParamsForTagsRE(), loopObj)
432
+
433
+ // Dot-path access
434
+ return this.getDataValFromPathStr(p2, ctx)
205
435
  }
206
436
 
207
- const mapStringData = (d, i) => typeof (d) === 'string' ? arrayStringToObjAdapter(d, subStr, i) : parseObject(d, subStr, i)
437
+ return str.replace(DomElementTemplate.swapParamsForTagsRE(), replacer)
438
+ }
439
+
440
+ /**
441
+ * Entry point for each top-level loop match. Extracts loop data, runs the
442
+ * inner-loop pass on the body for each iteration, then substitutes outer
443
+ * variables.
444
+ */
445
+ parseTheTmplLoop(str, p1, p2, p3) {
446
+ const outerBody = p3
447
+ const elData = DomElementTemplate.getNestedDataReducer(this.templateData, p2)
208
448
 
209
449
  if (isNil(elData) === true || isEmpty(elData)) {
210
450
  return ''
211
451
  }
212
452
 
213
- if (elData.length === undefined) {
214
- elData = [elData]
215
- }
453
+ // Stash the outer data so that per-iteration substitution can look up
454
+ // CMS proxy metadata (__cms__dataId, __cms__keyFor_*) that lives on the
455
+ // parent array object — not on individual string elements.
456
+ const previousOuter = this.currentOuterLoopData
457
+ this.currentOuterLoopData = elData
458
+
459
+ const elArr = Array.isArray(elData) ? elData : [elData]
460
+
461
+ // Whether the outer body contains an inner loop is cheap to test and lets
462
+ // us skip the inner-loop processing entirely for the common single-level case.
463
+ const bodyHasInnerLoop = DomElementTemplate.hasLoop(outerBody)
464
+
465
+ const rendered = elArr.map((item, index) => {
466
+ const body = bodyHasInnerLoop
467
+ ? this.processInnerLoop(outerBody, item)
468
+ : outerBody
216
469
 
217
- // convert to array if is string
218
- elData = Array.isArray(elData) ? elData : [elData]
470
+ return this.substituteForItem(body, item, index)
471
+ }).join('')
219
472
 
220
- return elData.map(mapStringData).join('')
473
+ this.currentOuterLoopData = previousOuter
474
+ return rendered
221
475
  }
222
476
  }
@@ -253,7 +253,7 @@ function ViewStreamSelector(cxt, sel) {
253
253
  * @param {String|HTMLElement} elSel The selector for the element.
254
254
  * @desc Sets the class active HTMLElement from a NodeList.
255
255
  */
256
- selector.setActiveItem = (c, elSel) => {
256
+ selector.setActiveItem2 = (c, elSel) => {
257
257
  const arr = getNodeListArray(cxt, sel)
258
258
  const currentEl = typeof (elSel) === 'string' ? getElOrList(cxt, elSel) : elSel
259
259
  const toggleBool = item => item.isEqualNode(currentEl) ? item.classList.add(c) : item.classList.remove(c)
@@ -266,6 +266,33 @@ function ViewStreamSelector(cxt, sel) {
266
266
  return this
267
267
  }
268
268
 
269
+ selector.setActiveItem = (c, elSel) => {
270
+ const arr = getNodeListArray(cxt, sel)
271
+ const currentEl = typeof elSel === 'string'
272
+ ? getElOrList(cxt, elSel)
273
+ : elSel
274
+
275
+ arr.forEach(item => item.classList.remove(c))
276
+
277
+ if (isNodeElement(currentEl) === true) {
278
+ const matchingEl = Array.from(arr).find(item => item === currentEl)
279
+
280
+ if (matchingEl) {
281
+ matchingEl.classList.add(c)
282
+ } else if (isDevMode() === true) {
283
+ console.warn(
284
+ `Spyne Warning: The selector, ${elSel}, is valid but does not match any item in setActiveItem: ${c}`
285
+ )
286
+ }
287
+ } else if (isDevMode() === true) {
288
+ console.warn(
289
+ `Spyne Warning: The selector, ${elSel}, does not appear to be a valid item in setActiveItem: ${c}`
290
+ )
291
+ }
292
+
293
+ return this
294
+ }
295
+
269
296
  /**
270
297
  *
271
298
  * @function el