@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.
@@ -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, 6);
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 only text
9
- * nodes were patched in place.
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 (patchTextNodes(element, strings, values)) {
23
+ if (patch(element, strings, values)) {
18
24
  return false;
19
25
  }
20
- fullRender(element, strings, values);
26
+ morph(element, strings, values);
21
27
  return true;
22
28
  }
23
29
 
24
30
  /**
25
- * Fast path: patch only the text nodes whose values changed.
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 = full render)
36
+ * @returns {boolean} Whether patching was sufficient (false = do morph instead)
31
37
  */
32
- function patchTextNodes(element, strings, values) {
38
+ function patch(element, strings, values) {
33
39
  // Only works when re-rendering the same template shape
34
- if (element._tplStrings !== strings || !element._tplParts) {
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 = Array.isArray(v) ? toPlainText(v) : v;
49
+ const comparable = isArray(v) ? toPlainText(v) : v;
41
50
 
42
- if (comparable === element._tplValues[i]) {
51
+ if (comparable === cached[i]) {
43
52
  continue;
44
53
  }
45
54
 
46
- if (isRaw(v) || !element._tplParts[i]) {
55
+ if (isRaw(v) && v !== nothing) {
47
56
  return false;
48
57
  }
49
58
 
50
- element._tplValues[i] = comparable;
51
- element._tplParts[i].textContent = toPlainText(v);
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
- * Cold path: clone a cached <template> and patch in values.
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 fullRender(element, strings, values) {
85
+ function morph(element, strings, values) {
65
86
  let entry = stringsCache.get(strings);
66
87
 
67
88
  if (!entry) {
68
- const processedStrings = Array.from(strings, collapseWhitespace);
89
+ const _strings = strings.map(collapseWhitespace);
69
90
  entry = {
70
- processedStrings,
71
- template: values.length > 0 ? createTemplate(processedStrings, values.length) : null,
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.template) {
77
- element._tplParts = cloneAndPatch(element, entry.template, values);
97
+ if (entry._template) {
98
+ element._templateParts = cloneAndPatch(element, entry._template, values);
78
99
  } else {
79
- // Fallback for attribute-position values or static templates.
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(value => resolveValue(value));
82
- const markup = entry.processedStrings
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 tpl = document.createElement("template");
89
- tpl.innerHTML = markup;
90
- morphContent(element, tpl.content.childNodes);
91
- element._tplParts = null;
109
+ const template = newTemplate();
110
+ template.innerHTML = markup;
111
+ morphContent(element, template.content.childNodes);
112
+ element._templateParts = null;
92
113
  }
93
114
 
94
- element._tplStrings = strings;
95
- element._tplValues = values.map(v => (Array.isArray(v) ? toPlainText(v) : v));
115
+ element._templateStrings = strings;
116
+ element._templateValues = values.map(v => (isArray(v) ? toPlainText(v) : v));
96
117
  }
97
118
 
98
119
  /**
99
- * Build a <template> element with comment markers.
120
+ * Create a <template> element with comment markers and string placeholders.
100
121
  *
101
- * @param {string[]} processedStrings - Whitespace-collapsed static parts
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(processedStrings, valueCount) {
126
+ function createTemplate(_strings, valueCount) {
106
127
  const marker = `<!--${markerKey}-->`;
107
- const markup = processedStrings
108
- .reduce((out, str, i) => out + str + (i < valueCount ? marker : ""), "")
109
- .trim();
128
+ const attrs = [];
129
+ let markup = "";
130
+
131
+ for (let i = 0; i < _strings.length; i++) {
132
+ markup += _strings[i];
110
133
 
111
- const tpl = document.createElement("template");
112
- tpl.innerHTML = markup;
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 = document.createTreeWalker(tpl.content, NodeFilter.SHOW_COMMENT);
116
- let count = 0;
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
- count++;
156
+ commentCount++;
121
157
  }
122
158
  }
123
159
 
124
- return count === valueCount ? tpl : null;
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 comment markers
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 {HTMLTemplateElement} template - Cached template with markers
173
+ * @param {{ _tpl: HTMLTemplateElement, _attrs: (string|null)[] }} templateInfo
133
174
  * @param {Array} values - Raw interpolated values
134
- * @returns {Array<Text | undefined>} Text node map for fast-path patching
175
+ * @returns {Array<Text | [Element, string] | undefined> | null}
135
176
  */
136
- function cloneAndPatch(element, template, values) {
137
- const clone = template.content.cloneNode(true);
138
- const walker = document.createTreeWalker(clone, NodeFilter.SHOW_COMMENT);
139
- const parts = new Array(values.length);
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
- for (let i = 0; i < markers.length; i++) {
151
- const value = values[i];
192
+ let contentIdx = 0;
193
+
194
+ for (let i = 0; i < values.length; i++) {
195
+ const attr = _attrs[i];
152
196
 
153
- if (isRaw(value)) {
154
- // Raw HTML: parse and insert as fragment
155
- const tmp = document.createElement("template");
156
- tmp.innerHTML = resolveValue(value);
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
- // Raw values can't be fast-patched; leave parts undefined
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
- // Create text node with unescaped content
162
- const textNode = document.createTextNode(toPlainText(value));
163
- markers[i].parentNode.replaceChild(textNode, markers[i]);
164
- parts[i] = textNode;
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 === Node.ELEMENT_NODE && cur.tagName !== nxt.tagName)
258
+ (cur.nodeType === ELEMENT_NODE && cur.tagName !== nxt.tagName)
195
259
  ) {
196
260
  parent.replaceChild(nxt, cur);
197
- } else if (cur.nodeType === Node.TEXT_NODE) {
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 === Node.ELEMENT_NODE) {
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
- * Morhp element’s attributes without rebuilding the DOM.
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
@@ -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
- if (typeof window !== "undefined" && "customElements" in window) {
9
- if (!window.customElements.get(tagName)) {
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 (Array.isArray(value)) {
35
- return value.map(item => resolveItem(item)).join("");
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?.__raw ? String(value) : escapeHtml(String(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 {{ __raw: true, strings: TemplateStringsArray, values: Array, toString(): string }}
86
+ * @returns {{ strings: TemplateStringsArray, values: Array, toString(): string }}
58
87
  */
59
88
  export function html(strings, ...values) {
60
- let str;
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 {{ __raw: true, toString(): string }}
96
+ * @returns {{ toString(): string }}
81
97
  */
82
98
  export function unsafeHTML(str) {
83
- return { __raw: true, toString: () => str ?? "" };
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 {{ __raw: true, toString(): string }}
106
+ * @type {{ toString(): string }}
91
107
  */
92
- export const nothing = Object.freeze({ __raw: true, toString: () => "" });
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(/>\n\s*/g, ">") // newline after tag close
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
  }