@elenajs/core 1.0.0-rc.7 → 1.0.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.
- package/README.md +66 -29
- package/dist/bundle.js +2 -2
- package/dist/common/props.d.ts +2 -2
- package/dist/common/props.d.ts.map +1 -1
- package/dist/common/render.d.ts +2 -2
- package/dist/common/render.d.ts.map +1 -1
- package/dist/common/utils.d.ts +7 -7
- package/dist/common/utils.d.ts.map +1 -1
- package/dist/elena.d.ts +6 -1
- package/dist/elena.d.ts.map +1 -1
- package/dist/elena.js +2 -2
- package/dist/props.js +1 -1
- package/dist/render.js +1 -1
- package/dist/utils.js +1 -1
- package/package.json +22 -10
- package/src/common/props.js +9 -10
- package/src/common/render.js +132 -68
- package/src/common/utils.js +47 -34
- package/src/elena.js +79 -69
package/src/common/render.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
|
-
import { collapseWhitespace, isRaw, resolveValue, toPlainText } from "./utils.js";
|
|
1
|
+
import { collapseWhitespace, isArray, isRaw, nothing, resolveValue, toPlainText } from "./utils.js";
|
|
2
2
|
|
|
3
3
|
const stringsCache = new WeakMap();
|
|
4
|
-
const markerKey = "e" + Math.random().toString(36).slice(2
|
|
4
|
+
const markerKey = "e" + Math.random().toString(36).slice(2);
|
|
5
|
+
const SHOW_COMMENT = 128;
|
|
6
|
+
const ELEMENT_NODE = 1;
|
|
7
|
+
const TEXT_NODE = 3;
|
|
8
|
+
|
|
9
|
+
const newTemplate = () => document.createElement("template");
|
|
10
|
+
const treeWalker = node => document.createTreeWalker(node, SHOW_COMMENT);
|
|
5
11
|
|
|
6
12
|
/**
|
|
7
13
|
* Render a tagged template into an Elena Element with DOM diffing.
|
|
8
|
-
* Returns true if the DOM was fully rebuilt, false if
|
|
9
|
-
*
|
|
14
|
+
* Returns true if the DOM was fully rebuilt, false if parts were
|
|
15
|
+
* patched in place.
|
|
10
16
|
*
|
|
11
17
|
* @param {HTMLElement} element
|
|
12
18
|
* @param {TemplateStringsArray} strings - Static parts of the tagged template
|
|
@@ -14,129 +20,165 @@ const markerKey = "e" + Math.random().toString(36).slice(2, 6);
|
|
|
14
20
|
* @returns {boolean}
|
|
15
21
|
*/
|
|
16
22
|
export function renderTemplate(element, strings, values) {
|
|
17
|
-
if (
|
|
23
|
+
if (patch(element, strings, values)) {
|
|
18
24
|
return false;
|
|
19
25
|
}
|
|
20
|
-
|
|
26
|
+
morph(element, strings, values);
|
|
21
27
|
return true;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/**
|
|
25
|
-
*
|
|
31
|
+
* Patch only changed text nodes and attribute values.
|
|
26
32
|
*
|
|
27
33
|
* @param {HTMLElement} element - The host element with cached template state
|
|
28
34
|
* @param {TemplateStringsArray} strings - Static parts of the tagged template
|
|
29
35
|
* @param {Array} values - Dynamic interpolated values
|
|
30
|
-
* @returns {boolean} Whether patching was sufficient (false =
|
|
36
|
+
* @returns {boolean} Whether patching was sufficient (false = do morph instead)
|
|
31
37
|
*/
|
|
32
|
-
function
|
|
38
|
+
function patch(element, strings, values) {
|
|
33
39
|
// Only works when re-rendering the same template shape
|
|
34
|
-
if (element.
|
|
40
|
+
if (element._templateStrings !== strings || !element._templateParts) {
|
|
35
41
|
return false;
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
const parts = element._templateParts;
|
|
45
|
+
const cached = element._templateValues;
|
|
46
|
+
|
|
38
47
|
for (let i = 0; i < values.length; i++) {
|
|
39
48
|
const v = values[i];
|
|
40
|
-
const comparable =
|
|
49
|
+
const comparable = isArray(v) ? toPlainText(v) : v;
|
|
41
50
|
|
|
42
|
-
if (comparable ===
|
|
51
|
+
if (comparable === cached[i]) {
|
|
43
52
|
continue;
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
if (isRaw(v)
|
|
55
|
+
if (isRaw(v) && v !== nothing) {
|
|
47
56
|
return false;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
const part = parts[i];
|
|
60
|
+
|
|
61
|
+
if (!part) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cached[i] = comparable;
|
|
66
|
+
const str = String(comparable ?? "");
|
|
67
|
+
|
|
68
|
+
if (part.nodeType) {
|
|
69
|
+
part.textContent = str;
|
|
70
|
+
} else {
|
|
71
|
+
part[0].setAttribute(part[1], str);
|
|
72
|
+
}
|
|
52
73
|
}
|
|
53
74
|
|
|
54
75
|
return true;
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
/**
|
|
58
|
-
*
|
|
79
|
+
* Clone a cached <template> and morph in new structure.
|
|
59
80
|
*
|
|
60
81
|
* @param {HTMLElement} element - The host element to render into
|
|
61
82
|
* @param {TemplateStringsArray} strings - Static parts of the tagged template
|
|
62
83
|
* @param {Array} values - Dynamic interpolated values
|
|
63
84
|
*/
|
|
64
|
-
function
|
|
85
|
+
function morph(element, strings, values) {
|
|
65
86
|
let entry = stringsCache.get(strings);
|
|
66
87
|
|
|
67
88
|
if (!entry) {
|
|
68
|
-
const
|
|
89
|
+
const _strings = strings.map(collapseWhitespace);
|
|
69
90
|
entry = {
|
|
70
|
-
|
|
71
|
-
|
|
91
|
+
_strings,
|
|
92
|
+
_template: values.length > 0 ? createTemplate(_strings, values.length) : null,
|
|
72
93
|
};
|
|
73
94
|
stringsCache.set(strings, entry);
|
|
74
95
|
}
|
|
75
96
|
|
|
76
|
-
if (entry.
|
|
77
|
-
element.
|
|
97
|
+
if (entry._template) {
|
|
98
|
+
element._templateParts = cloneAndPatch(element, entry._template, values);
|
|
78
99
|
} else {
|
|
79
|
-
// Fallback for
|
|
100
|
+
// Fallback for static templates or templates where marker detection failed.
|
|
80
101
|
// White space collapsing here protects against Vue SSR mismatches.
|
|
81
|
-
const renderedValues = values.map(
|
|
82
|
-
const markup = entry.
|
|
102
|
+
const renderedValues = values.map(resolveValue);
|
|
103
|
+
const markup = entry._strings
|
|
83
104
|
.reduce((out, str, i) => out + str + (renderedValues[i] ?? ""), "")
|
|
84
105
|
.replace(/>\s+</g, "><")
|
|
85
106
|
.trim();
|
|
86
107
|
|
|
87
108
|
// Morph existing DOM to match new markup instead of replacing it.
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
morphContent(element,
|
|
91
|
-
element.
|
|
109
|
+
const template = newTemplate();
|
|
110
|
+
template.innerHTML = markup;
|
|
111
|
+
morphContent(element, template.content.childNodes);
|
|
112
|
+
element._templateParts = null;
|
|
92
113
|
}
|
|
93
114
|
|
|
94
|
-
element.
|
|
95
|
-
element.
|
|
115
|
+
element._templateStrings = strings;
|
|
116
|
+
element._templateValues = values.map(v => (isArray(v) ? toPlainText(v) : v));
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
/**
|
|
99
|
-
*
|
|
120
|
+
* Create a <template> element with comment markers and string placeholders.
|
|
100
121
|
*
|
|
101
|
-
* @param {string[]}
|
|
122
|
+
* @param {string[]} _strings - Whitespace-collapsed static parts
|
|
102
123
|
* @param {number} valueCount - Number of dynamic values
|
|
103
|
-
* @returns {HTMLTemplateElement | null}
|
|
124
|
+
* @returns {{ _tpl: HTMLTemplateElement, _attrs: (string|null)[] } | null}
|
|
104
125
|
*/
|
|
105
|
-
function createTemplate(
|
|
126
|
+
function createTemplate(_strings, valueCount) {
|
|
106
127
|
const marker = `<!--${markerKey}-->`;
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
128
|
+
const attrs = [];
|
|
129
|
+
let markup = "";
|
|
130
|
+
|
|
131
|
+
for (let i = 0; i < _strings.length; i++) {
|
|
132
|
+
markup += _strings[i];
|
|
110
133
|
|
|
111
|
-
|
|
112
|
-
|
|
134
|
+
if (i < valueCount) {
|
|
135
|
+
const match = _strings[i].match(/([^\s"'>/=]+)\s*=\s*["']$/);
|
|
136
|
+
|
|
137
|
+
if (match) {
|
|
138
|
+
attrs.push(match[1]);
|
|
139
|
+
markup += markerKey + "_" + i;
|
|
140
|
+
} else {
|
|
141
|
+
attrs.push(null);
|
|
142
|
+
markup += marker;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const template = newTemplate();
|
|
148
|
+
template.innerHTML = markup.trim();
|
|
113
149
|
|
|
114
150
|
// Mismatch means this template shape cannot use the clone path.
|
|
115
|
-
const walker =
|
|
116
|
-
let
|
|
151
|
+
const walker = treeWalker(template.content);
|
|
152
|
+
let commentCount = 0;
|
|
117
153
|
|
|
118
154
|
while (walker.nextNode()) {
|
|
119
155
|
if (walker.currentNode.data === markerKey) {
|
|
120
|
-
|
|
156
|
+
commentCount++;
|
|
121
157
|
}
|
|
122
158
|
}
|
|
123
159
|
|
|
124
|
-
|
|
160
|
+
const expectedComments = attrs.filter(n => n === null).length;
|
|
161
|
+
|
|
162
|
+
if (commentCount !== expectedComments) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { _tpl: template, _attrs: attrs };
|
|
125
167
|
}
|
|
126
168
|
|
|
127
169
|
/**
|
|
128
|
-
* Clone a cached template and replace
|
|
129
|
-
* with actual content.
|
|
170
|
+
* Clone a cached template and replace markers with actual content.
|
|
130
171
|
*
|
|
131
172
|
* @param {HTMLElement} element - The host element to render into
|
|
132
|
-
* @param {
|
|
173
|
+
* @param {{ _tpl: HTMLTemplateElement, _attrs: (string|null)[] }} templateInfo
|
|
133
174
|
* @param {Array} values - Raw interpolated values
|
|
134
|
-
* @returns {Array<Text |
|
|
175
|
+
* @returns {Array<Text | [Element, string] | undefined> | null}
|
|
135
176
|
*/
|
|
136
|
-
function cloneAndPatch(element,
|
|
137
|
-
const
|
|
138
|
-
const
|
|
139
|
-
const
|
|
177
|
+
function cloneAndPatch(element, templateInfo, values) {
|
|
178
|
+
const { _tpl, _attrs } = templateInfo;
|
|
179
|
+
const clone = _tpl.content.cloneNode(true);
|
|
180
|
+
const walker = treeWalker(clone);
|
|
181
|
+
const parts = Array(values.length);
|
|
140
182
|
const markers = [];
|
|
141
183
|
let node;
|
|
142
184
|
|
|
@@ -147,24 +189,46 @@ function cloneAndPatch(element, template, values) {
|
|
|
147
189
|
}
|
|
148
190
|
}
|
|
149
191
|
|
|
150
|
-
|
|
151
|
-
|
|
192
|
+
let contentIdx = 0;
|
|
193
|
+
|
|
194
|
+
for (let i = 0; i < values.length; i++) {
|
|
195
|
+
const attr = _attrs[i];
|
|
152
196
|
|
|
153
|
-
if (
|
|
154
|
-
//
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
markers[i].parentNode.replaceChild(tmp.content, markers[i]);
|
|
197
|
+
if (attr) {
|
|
198
|
+
// Find the element with the placeholder value
|
|
199
|
+
const placeholder = markerKey + "_" + i;
|
|
200
|
+
const el = clone.querySelector(`[${attr}="${placeholder}"]`);
|
|
158
201
|
|
|
159
|
-
|
|
202
|
+
if (el) {
|
|
203
|
+
const value = values[i];
|
|
204
|
+
const str = String((isArray(value) ? toPlainText(value) : value) ?? "");
|
|
205
|
+
el.setAttribute(attr, str);
|
|
206
|
+
parts[i] = [el, attr];
|
|
207
|
+
}
|
|
160
208
|
} else {
|
|
161
|
-
//
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
209
|
+
// Replace comment marker with value
|
|
210
|
+
const marker = markers[contentIdx++];
|
|
211
|
+
const value = values[i];
|
|
212
|
+
|
|
213
|
+
// Parse and insert raw HTML as a fragment
|
|
214
|
+
if (isRaw(value) && value !== nothing) {
|
|
215
|
+
const tmp = newTemplate();
|
|
216
|
+
tmp.innerHTML = resolveValue(value);
|
|
217
|
+
marker.parentNode.replaceChild(tmp.content, marker);
|
|
218
|
+
|
|
219
|
+
// Create text node with unescaped content
|
|
220
|
+
} else {
|
|
221
|
+
const textNode = document.createTextNode(toPlainText(value));
|
|
222
|
+
marker.parentNode.replaceChild(textNode, marker);
|
|
223
|
+
parts[i] = textNode;
|
|
224
|
+
}
|
|
165
225
|
}
|
|
166
226
|
}
|
|
167
227
|
|
|
228
|
+
if (element._templateStrings) {
|
|
229
|
+
morphContent(element, clone.childNodes);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
168
232
|
element.replaceChildren(clone);
|
|
169
233
|
return parts;
|
|
170
234
|
}
|
|
@@ -191,14 +255,14 @@ function morphContent(parent, nextNodes) {
|
|
|
191
255
|
parent.removeChild(cur);
|
|
192
256
|
} else if (
|
|
193
257
|
cur.nodeType !== nxt.nodeType ||
|
|
194
|
-
(cur.nodeType ===
|
|
258
|
+
(cur.nodeType === ELEMENT_NODE && cur.tagName !== nxt.tagName)
|
|
195
259
|
) {
|
|
196
260
|
parent.replaceChild(nxt, cur);
|
|
197
|
-
} else if (cur.nodeType ===
|
|
261
|
+
} else if (cur.nodeType === TEXT_NODE) {
|
|
198
262
|
if (cur.textContent !== nxt.textContent) {
|
|
199
263
|
cur.textContent = nxt.textContent;
|
|
200
264
|
}
|
|
201
|
-
} else if (cur.nodeType ===
|
|
265
|
+
} else if (cur.nodeType === ELEMENT_NODE) {
|
|
202
266
|
morphAttributes(cur, nxt);
|
|
203
267
|
morphContent(cur, nxt.childNodes);
|
|
204
268
|
}
|
|
@@ -206,7 +270,7 @@ function morphContent(parent, nextNodes) {
|
|
|
206
270
|
}
|
|
207
271
|
|
|
208
272
|
/**
|
|
209
|
-
*
|
|
273
|
+
* Morph element’s attributes without rebuilding the DOM.
|
|
210
274
|
*
|
|
211
275
|
* @param {Element} current - The current existing DOM element
|
|
212
276
|
* @param {Element} next - The desired element from the new render
|
package/src/common/utils.js
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
|
+
const prefix = "░█ [ELENA]: ";
|
|
2
|
+
const isArray = Array.isArray;
|
|
3
|
+
const RAW = Symbol("elena.raw");
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} msg
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export const warn = msg => console.warn(prefix + msg);
|
|
10
|
+
export { prefix, isArray };
|
|
11
|
+
|
|
1
12
|
/**
|
|
2
13
|
* Register the Elena Element if the browser supports it.
|
|
3
14
|
*
|
|
4
15
|
* @param {string} tagName
|
|
5
16
|
* @param {Function} Element
|
|
6
17
|
*/
|
|
7
|
-
export function defineElement(tagName, Element) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
window.customElements.define(tagName, Element);
|
|
11
|
-
}
|
|
12
|
-
}
|
|
18
|
+
export function defineElement(tagName, Element, registry) {
|
|
19
|
+
const reg = registry ?? globalThis.customElements;
|
|
20
|
+
reg?.get(tagName) || reg?.define(tagName, Element);
|
|
13
21
|
}
|
|
14
22
|
|
|
15
23
|
/**
|
|
@@ -31,8 +39,8 @@ export function escapeHtml(str) {
|
|
|
31
39
|
* @returns {string}
|
|
32
40
|
*/
|
|
33
41
|
export function resolveValue(value) {
|
|
34
|
-
if (
|
|
35
|
-
return value.map(
|
|
42
|
+
if (isArray(value)) {
|
|
43
|
+
return value.map(resolveItem).join("");
|
|
36
44
|
}
|
|
37
45
|
return resolveItem(value);
|
|
38
46
|
}
|
|
@@ -45,8 +53,29 @@ export function resolveValue(value) {
|
|
|
45
53
|
* @returns {string}
|
|
46
54
|
*/
|
|
47
55
|
function resolveItem(value) {
|
|
48
|
-
return value?.
|
|
56
|
+
return value?.[RAW] ? String(value) : escapeHtml(value ?? "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Lightweight template result.
|
|
61
|
+
*
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
class HtmlResult {
|
|
65
|
+
constructor(strings, values) {
|
|
66
|
+
this.strings = strings;
|
|
67
|
+
this.values = values;
|
|
68
|
+
}
|
|
69
|
+
toString() {
|
|
70
|
+
if (this._str == null) {
|
|
71
|
+
this._str = this.strings.reduce((acc, s, i) => {
|
|
72
|
+
return acc + s + resolveValue(this.values[i]);
|
|
73
|
+
}, "");
|
|
74
|
+
}
|
|
75
|
+
return this._str;
|
|
76
|
+
}
|
|
49
77
|
}
|
|
78
|
+
HtmlResult.prototype[RAW] = true;
|
|
50
79
|
|
|
51
80
|
/**
|
|
52
81
|
* Tagged template for trusted HTML. Use as the return value
|
|
@@ -54,42 +83,29 @@ function resolveItem(value) {
|
|
|
54
83
|
*
|
|
55
84
|
* @param {TemplateStringsArray} strings
|
|
56
85
|
* @param {...*} values
|
|
57
|
-
* @returns {{
|
|
86
|
+
* @returns {{ strings: TemplateStringsArray, values: Array, toString(): string }}
|
|
58
87
|
*/
|
|
59
88
|
export function html(strings, ...values) {
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
__raw: true,
|
|
63
|
-
strings,
|
|
64
|
-
values,
|
|
65
|
-
toString: () => {
|
|
66
|
-
if (str === undefined) {
|
|
67
|
-
str = strings.reduce((acc, s, i) => {
|
|
68
|
-
return acc + s + resolveValue(values[i]);
|
|
69
|
-
}, "");
|
|
70
|
-
}
|
|
71
|
-
return str;
|
|
72
|
-
},
|
|
73
|
-
};
|
|
89
|
+
return new HtmlResult(strings, values);
|
|
74
90
|
}
|
|
75
91
|
|
|
76
92
|
/**
|
|
77
93
|
* Renders a string as HTML rather than text.
|
|
78
94
|
*
|
|
79
95
|
* @param {string} str - The raw HTML string to trust.
|
|
80
|
-
* @returns {{
|
|
96
|
+
* @returns {{ toString(): string }}
|
|
81
97
|
*/
|
|
82
98
|
export function unsafeHTML(str) {
|
|
83
|
-
return {
|
|
99
|
+
return { [RAW]: true, toString: () => str ?? "" };
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
/**
|
|
87
103
|
* A placeholder you can return from a conditional expression
|
|
88
104
|
* inside a template to render nothing.
|
|
89
105
|
*
|
|
90
|
-
* @type {{
|
|
106
|
+
* @type {{ toString(): string }}
|
|
91
107
|
*/
|
|
92
|
-
export const nothing =
|
|
108
|
+
export const nothing = { [RAW]: true, toString: () => "" };
|
|
93
109
|
|
|
94
110
|
/**
|
|
95
111
|
* Check if a value contains trusted HTML fragments.
|
|
@@ -97,8 +113,7 @@ export const nothing = Object.freeze({ __raw: true, toString: () => "" });
|
|
|
97
113
|
* @param {*} value
|
|
98
114
|
* @returns {boolean}
|
|
99
115
|
*/
|
|
100
|
-
export const isRaw = value =>
|
|
101
|
-
Array.isArray(value) ? value.some(item => item?.__raw) : value?.__raw;
|
|
116
|
+
export const isRaw = value => (isArray(value) ? value.some(item => item?.[RAW]) : !!value?.[RAW]);
|
|
102
117
|
|
|
103
118
|
/**
|
|
104
119
|
* Convert a value to its plain text string.
|
|
@@ -106,8 +121,7 @@ export const isRaw = value =>
|
|
|
106
121
|
* @param {*} value
|
|
107
122
|
* @returns {string}
|
|
108
123
|
*/
|
|
109
|
-
export const toPlainText = value =>
|
|
110
|
-
Array.isArray(value) ? value.map(item => String(item ?? "")).join("") : String(value ?? "");
|
|
124
|
+
export const toPlainText = value => (isArray(value) ? value.join("") : String(value ?? ""));
|
|
111
125
|
|
|
112
126
|
/**
|
|
113
127
|
* Collapse whitespace from a static string part.
|
|
@@ -117,8 +131,7 @@ export const toPlainText = value =>
|
|
|
117
131
|
*/
|
|
118
132
|
export function collapseWhitespace(string) {
|
|
119
133
|
return string
|
|
120
|
-
.replace(
|
|
121
|
-
.replace(/\n\s*</g, "<") // newline before tag open
|
|
134
|
+
.replace(/(>)\n\s*|\n\s*(<)/g, "$1$2") // newlines adjacent to tags
|
|
122
135
|
.replace(/\n\s*/g, " ") // newline in text content, preserve word boundary
|
|
123
136
|
.replace(/>\s+</g, "><"); // whitespace between tags
|
|
124
137
|
}
|