spyne 0.21.4 → 0.22.3
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/.github/workflows/publish.yml +21 -16
- package/.github/workflows/tests.yml +28 -0
- package/README.md +1 -1
- package/karma.conf.js +37 -24
- package/lib/spyne.esm.js +3 -3
- package/lib/spyne.esm.js.LICENSE.txt +16 -0
- package/lib/spyne.esm.js.map +1 -1
- package/lib/spyne.umd.js +2 -2
- package/lib/spyne.umd.js.LICENSE.txt +2 -2
- package/package.json +10 -7
- package/rollup.config.js +1 -1
- package/src/spyne/channels/channels-map.js +4 -0
- package/src/spyne/channels/spyne-channel-ai.js +45 -0
- package/src/spyne/spyne-app.js +2 -2
- package/src/spyne/utils/channel-payload-filter.js +1 -1
- package/src/spyne/utils/sanitize-data.js +2 -2
- package/src/spyne/views/dom-element-template.js +308 -54
- package/src/spyne/views/view-stream-selector.js +28 -1
- package/src/tests/views/dom-el-template.test.js +173 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spyne",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.3",
|
|
4
4
|
"description": "Reactive Real-DOM Framework for Advanced Javascript applications",
|
|
5
5
|
"sideEffects": true,
|
|
6
6
|
"main": "./lib/spyne.esm.js",
|
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
"eslint-fix-tests": "eslint --fix 'src/tests/**'",
|
|
19
19
|
"report-coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov",
|
|
20
20
|
"commit": "git-cz",
|
|
21
|
-
"start": "webpack-dev-server --hot",
|
|
22
21
|
"build": "webpack --config webpack.config.js --mode production",
|
|
23
22
|
"build:esm": "rollup --config rollup.config.js",
|
|
24
23
|
"dev": "webpack --config webpack.config.js --mode development",
|
|
@@ -52,6 +51,7 @@
|
|
|
52
51
|
"@rollup/plugin-json": "^6.1.0",
|
|
53
52
|
"@rollup/plugin-node-resolve": "^16.0.0",
|
|
54
53
|
"@rollup/plugin-replace": "^6.0.2",
|
|
54
|
+
"@rollup/plugin-terser": "^1.0.0",
|
|
55
55
|
"chai": "^4.3.9",
|
|
56
56
|
"chai-dom": "^1.11.0",
|
|
57
57
|
"clean-webpack-plugin": "^4.0.0",
|
|
@@ -72,19 +72,22 @@
|
|
|
72
72
|
"karma-mocha": "^2.0.1",
|
|
73
73
|
"karma-rollup-preprocessor": "^7.0.8",
|
|
74
74
|
"karma-webpack": "^5.0.1",
|
|
75
|
-
"mocha": "^
|
|
75
|
+
"mocha": "^11.7.5",
|
|
76
76
|
"rollup": "^2.79.2",
|
|
77
|
-
"rollup-plugin-terser": "^7.0.2",
|
|
78
77
|
"webpack": "^5.94.0",
|
|
79
78
|
"webpack-cli": "^5.1.4",
|
|
80
|
-
"webpack-dev-middleware": "^7.4.2",
|
|
81
|
-
"webpack-dev-server": "^5.1.0",
|
|
82
79
|
"webpack-rxjs-externals": "^2.0.0"
|
|
83
80
|
},
|
|
81
|
+
"overrides": {
|
|
82
|
+
"mocha": {
|
|
83
|
+
"serialize-javascript": "^7.0.4",
|
|
84
|
+
"diff": "^8.0.2"
|
|
85
|
+
}
|
|
86
|
+
},
|
|
84
87
|
"config": {},
|
|
85
88
|
"dependencies": {
|
|
86
89
|
"dompurify": "^3.1.6",
|
|
87
|
-
"ramda": "^0.
|
|
90
|
+
"ramda": "^0.32.0",
|
|
88
91
|
"rxjs": "^7.8.1"
|
|
89
92
|
}
|
|
90
93
|
}
|
package/rollup.config.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import resolve from '@rollup/plugin-node-resolve';
|
|
3
3
|
import commonjs from '@rollup/plugin-commonjs';
|
|
4
4
|
import json from '@rollup/plugin-json';
|
|
5
|
-
import
|
|
5
|
+
import terser from '@rollup/plugin-terser';
|
|
6
6
|
|
|
7
7
|
export default {
|
|
8
8
|
input: 'src/spyne/spyne.js', // Your main entry (the "barrel")
|
|
@@ -2,6 +2,7 @@ import { SpyneChannelRoute } from './spyne-channel-route.js'
|
|
|
2
2
|
import { SpyneChannelUI } from './spyne-channel-ui.js'
|
|
3
3
|
import { SpyneChannelWindow } from './spyne-channel-window.js'
|
|
4
4
|
import { SpyneChannelLifecycle } from './spyne-channel-lifecycle.js'
|
|
5
|
+
import { SpyneChannelAI } from './spyne-channel-ai.js'
|
|
5
6
|
|
|
6
7
|
import { Subject } from 'rxjs'
|
|
7
8
|
import { ChannelProxy } from './channel-proxy.js'
|
|
@@ -84,6 +85,9 @@ export class ChannelsMap {
|
|
|
84
85
|
this.viewStreamLifecycle = new SpyneChannelLifecycle()
|
|
85
86
|
_map.set('CHANNEL_LIFECYCLE', this.viewStreamLifecycle)
|
|
86
87
|
|
|
88
|
+
this.aiStream = new SpyneChannelAI()
|
|
89
|
+
_map.set('CHANNEL_AI', this.aiStream)
|
|
90
|
+
|
|
87
91
|
this.routeStream.initializeStream()
|
|
88
92
|
this.domStream.initializeStream()
|
|
89
93
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Channel } from './channel.js'
|
|
2
|
+
|
|
3
|
+
export class SpyneChannelAI extends Channel {
|
|
4
|
+
/**
|
|
5
|
+
* @module SpyneChannelAI
|
|
6
|
+
* @type core
|
|
7
|
+
*
|
|
8
|
+
* @desc
|
|
9
|
+
* Internal Channel that publishes rendering and disposing events of all ViewStreams whose property, proper.sendAIEvents is set to true.
|
|
10
|
+
*
|
|
11
|
+
* <h3>The two actions that are regsitered for this channel are:</h3>
|
|
12
|
+
* <ul>
|
|
13
|
+
* <li>CHANNEL_AI_RENDERED_EVENT</li>
|
|
14
|
+
* <li>CHANNEL_AI_DISPOSED_EVENT</li>
|
|
15
|
+
* </ul>
|
|
16
|
+
* @constructor
|
|
17
|
+
* @property {String} CHANNEL_NAME - = 'CHANNEL_AI';
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
constructor(props = {}) {
|
|
21
|
+
super('CHANNEL_AI', props)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
addRegisteredActions() {
|
|
25
|
+
return [
|
|
26
|
+
'CHANNEL_AI_RENDERED_EVENT',
|
|
27
|
+
'CHANNEL_AI_DISPOSED_EVENT'
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
onViewStreamInfo(obj) {
|
|
32
|
+
const { action, srcElement } = obj
|
|
33
|
+
const payload = srcElement
|
|
34
|
+
payload.action = action
|
|
35
|
+
this.onSendEvent(action, payload)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
onSendEvent(actionStr, payload = {}) {
|
|
39
|
+
const action = this.channelActions[actionStr]
|
|
40
|
+
const srcElement = {}
|
|
41
|
+
const event = undefined
|
|
42
|
+
const delayStream = () => this.sendChannelPayload(action, payload, srcElement, event)
|
|
43
|
+
window.setTimeout(delayStream, 0)
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/spyne/spyne-app.js
CHANGED
|
@@ -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.
|
|
9
|
+
const version = '0.22.3'
|
|
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.
|
|
44
|
+
* Spyne 0.22.3
|
|
45
45
|
* https://spynejs.org
|
|
46
46
|
*
|
|
47
47
|
* @license
|
|
@@ -233,7 +233,7 @@ export class ChannelPayloadFilter {
|
|
|
233
233
|
// LOOP THROUGH NODES IN querySelectorAll()
|
|
234
234
|
const mapNodeArrWithEl = (sel) => {
|
|
235
235
|
// convert nodelist to array of els
|
|
236
|
-
const nodeArr =
|
|
236
|
+
const nodeArr = Array.from(document.querySelectorAll(sel))
|
|
237
237
|
// els array to boolean array
|
|
238
238
|
return rMap(compareEls, nodeArr)
|
|
239
239
|
}
|
|
@@ -11,7 +11,7 @@ let forceStrict = false // legacy compatibility toggle
|
|
|
11
11
|
* ------------------------------------------- */
|
|
12
12
|
const SAFE_FOR_RICH_TEXT = {
|
|
13
13
|
ALLOWED_TAGS: [
|
|
14
|
-
'a', 'b', 'i', 'em', 'strong', 'u', 'p', 'br', 'ul', 'ol', 'li',
|
|
14
|
+
'a', 'b', 'i', 'em', 'strong', 'u', 'p', 'br', 'ul', 'dl', 'dt', 'dd', 'ol', 'li', 'hr',
|
|
15
15
|
'blockquote', 'pre', 'code', 'span', 'div', 'section', 'article', 'aside',
|
|
16
16
|
'header', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
17
17
|
'table', 'thead', 'tbody', 'tr', 'td', 'th', 'img', 'figure', 'figcaption',
|
|
@@ -24,7 +24,7 @@ const SAFE_FOR_RICH_TEXT = {
|
|
|
24
24
|
],
|
|
25
25
|
ALLOW_DATA_ATTR: true,
|
|
26
26
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
|
|
27
|
-
FORBID_TAGS: ['script', 'object', 'embed', 'meta'
|
|
27
|
+
FORBID_TAGS: ['script', 'object', 'embed', 'meta'],
|
|
28
28
|
SAFE_URL_PATTERN: /^(https?:|mailto:|tel:|data:image\/)/i
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -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 =
|
|
87
|
+
const parseTmplLoopFn = this.parseTheTmplLoop.bind(this)
|
|
37
88
|
|
|
38
|
-
const mapTmplLoop = (str
|
|
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 =
|
|
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]+?{{
|
|
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 =
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
296
|
+
const innerData = DomElementTemplate.getNestedDataReducer(parentItem, innerKey)
|
|
297
|
+
|
|
298
|
+
if (isNil(innerData) === true || isEmpty(innerData)) {
|
|
299
|
+
return ''
|
|
158
300
|
}
|
|
159
301
|
|
|
160
|
-
const
|
|
161
|
-
const spyneLoopKey = d
|
|
162
|
-
const loopIndex = i
|
|
163
|
-
const loopNum = i + 1
|
|
302
|
+
const innerArr = Array.isArray(innerData) ? innerData : [innerData]
|
|
164
303
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
187
|
-
|
|
394
|
+
return context
|
|
395
|
+
}
|
|
188
396
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
432
|
+
|
|
433
|
+
// Dot-path access
|
|
434
|
+
return this.getDataValFromPathStr(p2, ctx)
|
|
205
435
|
}
|
|
206
436
|
|
|
207
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
470
|
+
return this.substituteForItem(body, item, index)
|
|
471
|
+
}).join('')
|
|
219
472
|
|
|
220
|
-
|
|
473
|
+
this.currentOuterLoopData = previousOuter
|
|
474
|
+
return rendered
|
|
221
475
|
}
|
|
222
476
|
}
|