@webtides/element-js 0.7.3 → 1.0.0-alpha.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.
@@ -0,0 +1,332 @@
1
+ import { COMMENT_NODE, getNodePath } from '../util/DOMHelper';
2
+ import { encodeAttribute, isObjectLike } from '../util/AttributeParser.js';
3
+ import { TemplatePart } from './TemplatePart.js';
4
+
5
+ const voidElements = /^(?:area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)$/i;
6
+ const elements = /<([a-z]+[a-z0-9:._-]*)([^>]*?)(\/?)>/g;
7
+ // TODO: v this will not match any values with escaped quotes like onClick='console.log("\'test")'
8
+ const attributes = /([^\s]*)=((?:")[^"]*(?:")|(?:')[^']*(?:')|[^\s\/>]*)|([^\s\/>]*)/g;
9
+ const partPositions = /[\x01\x02]/g;
10
+ // \x01 Node.ELEMENT_NODE
11
+ // \x02 Node.ATTRIBUTE_NODE
12
+ // \x03 COMMENT.ATTRIBUTE_TOKEN
13
+ // \x04 Node.ATTRIBUTE_TOKEN
14
+
15
+ const interpolation = new RegExp(`(<!--dom-part-(\\d+)--><!--/dom-part-(\\d+)-->|(\\S\\w+)="\x04(")?|\x04)`, 'g');
16
+
17
+ /**
18
+ * Given a template, find part positions as both nodes and attributes and
19
+ * return a string with placeholders as either comment nodes or named attributes.
20
+ * @param {TemplateStringsArray | string[]} templateStrings a template literal tag array
21
+ * @param {string} attributePlaceholders replace placeholders inside attribute values with this value
22
+ * @returns {string} X/HTML with prefixed comments or attributes
23
+ */
24
+ export const createTemplateString = (templateStrings, attributePlaceholders = '') => {
25
+ // TODO: make it easier to identify attribute and node parts for SSR and leave the comments at those positions to be replaced in toString()
26
+ let partIndex = 0;
27
+ // join all interpolations (for values) with a special placeholder and remove any whitespace
28
+ let template = templateStrings.join('\x01').trim();
29
+ // find (match) all elements to identify their attributes
30
+ template = template.replace(elements, (_, name, attributesString, trailingSlash) => {
31
+ let elementTagWithAttributes = name + attributesString.replaceAll('\x01', attributePlaceholders).trimEnd();
32
+ if (trailingSlash.length) elementTagWithAttributes += voidElements.test(name) ? ' /' : '></' + name;
33
+ // collect all attribute parts so that we can place matching comment nodes
34
+ const attributeParts = attributesString.replace(attributes, (attribute, name, valueWithQuotes, directive) => {
35
+ if (directive) {
36
+ return `<!--\x02$-->`;
37
+ }
38
+
39
+ // remove quotes from attribute value to normalize the value
40
+ const value =
41
+ valueWithQuotes?.startsWith('"') || valueWithQuotes?.startsWith("'")
42
+ ? valueWithQuotes.slice(1, -1)
43
+ : valueWithQuotes;
44
+ const partsCount = (attribute.match(/\x01/g) || []).length;
45
+ const parts = [];
46
+ for (let index = 0; index < partsCount; index++) {
47
+ parts.push(`<!--\x02:${name}=${value.replaceAll('\x01', '\x03')}-->`);
48
+ }
49
+ return parts.join('');
50
+ });
51
+ return `
52
+ ${attributesString.includes('\x01') ? attributeParts : ''}
53
+ <${elementTagWithAttributes}>
54
+ `;
55
+ });
56
+ // replace interpolation placeholders with our indexed markers
57
+ template = template.replace(partPositions, (partPosition) => {
58
+ if (partPosition === '\x01') {
59
+ return `<!--dom-part-${partIndex}--><!--/dom-part-${partIndex++}-->`;
60
+ }
61
+ if (partPosition === '\x02') {
62
+ return `dom-part-${partIndex++}`;
63
+ }
64
+ });
65
+ return `
66
+ <!--template-part-->
67
+ ${template}
68
+ <!--/template-part-->
69
+ `;
70
+ // TODO: ^ this is actually important because of the whitespace
71
+ };
72
+
73
+ /**
74
+ * @param {String} name
75
+ * @param {any} value
76
+ * @param {Boolean} isSingleValue
77
+ * @return {String}
78
+ */
79
+ const attribute = (name, value, isSingleValue = true) => {
80
+ return ` ${name}="${encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value)}${
81
+ isSingleValue ? '"' : ''
82
+ }`;
83
+ };
84
+
85
+ /**
86
+ * @param {any} value
87
+ * @return {String}
88
+ */
89
+ const getValue = (value) => {
90
+ switch (typeof value) {
91
+ case 'string':
92
+ return encodeAttribute(value);
93
+ case 'boolean':
94
+ case 'number':
95
+ return String(value);
96
+ case 'object':
97
+ switch (true) {
98
+ case Array.isArray(value):
99
+ return value.map(getValue).join('');
100
+ case value instanceof TemplateResult:
101
+ return value.toString();
102
+ }
103
+ break;
104
+ case 'function':
105
+ return getValue(value());
106
+ }
107
+ return value == null ? '' : encodeAttribute(String(value));
108
+ };
109
+
110
+ /** @type {Map<TemplateStringsArray, *[]>} */
111
+ const parsedUpdates = new WeakMap();
112
+
113
+ /** @type {Map<Element, TemplatePart>} */
114
+ const templateParts = new WeakMap();
115
+
116
+ export class TemplateResult {
117
+ /**
118
+ * @param {TemplateStringsArray} strings
119
+ * @param {any[]} values
120
+ */
121
+ constructor(strings, ...values) {
122
+ this.strings = strings;
123
+ this.values = values;
124
+ }
125
+
126
+ /**
127
+ * @param {Element} domNode
128
+ */
129
+ renderInto(domNode) {
130
+ let serverSideRendered = false;
131
+ let templatePart = templateParts.get(domNode);
132
+ if (!templatePart) {
133
+ const startNode = Array.from(domNode.childNodes)
134
+ .filter((node) => node.nodeType === COMMENT_NODE)
135
+ .find((node) => node.data === 'template-part');
136
+
137
+ serverSideRendered = startNode !== undefined;
138
+
139
+ templatePart = new TemplatePart(startNode, this);
140
+ templateParts.set(domNode, templatePart);
141
+
142
+ if (!serverSideRendered) {
143
+ domNode.replaceChildren(...templatePart.childNodes);
144
+ }
145
+ } else {
146
+ templatePart.update(this);
147
+ }
148
+ }
149
+
150
+ /**
151
+ * @param {String[]} strings
152
+ * @return {(() => String)[]}
153
+ */
154
+ parse(strings) {
155
+ const html = createTemplateString(strings, '\x04');
156
+ const parts = [];
157
+ let i = 0;
158
+ let match = null;
159
+ while ((match = interpolation.exec(html))) {
160
+ const pre = html.slice(i, match.index);
161
+ i = match.index + match[0].length;
162
+ if (match[2]) {
163
+ // ChildNodePart
164
+ const index = match[2];
165
+ parts.push((value) => `${pre}<!--dom-part-${index}-->${getValue(value)}<!--/dom-part-${index}-->`);
166
+ } else if (match[4]) {
167
+ // AttributePart with single interpolation or the first interpolation right after the attribute=
168
+ const isSingleValue = match[5] !== undefined;
169
+ let name = match[4];
170
+ switch (true) {
171
+ case name[0] === '?':
172
+ const booleanName = name.slice(1).toLowerCase();
173
+ parts.push((value) => {
174
+ if (!value) return '';
175
+ return `${pre} ${booleanName}=""`;
176
+ });
177
+ break;
178
+ case name[0] === '.':
179
+ const lower = name.slice(1).toLowerCase();
180
+ parts.push((value) => {
181
+ let result = pre;
182
+ // null, undefined, and false are not shown at all
183
+ if (value === null || value === undefined || value === '') {
184
+ result += attribute(lower, '');
185
+ } else {
186
+ // in all other cases, just escape it in quotes
187
+ result += attribute(lower, value, isSingleValue);
188
+ }
189
+ return result;
190
+ });
191
+ break;
192
+ case name[0] === '@':
193
+ name = 'on' + name.slice(1);
194
+ case name[0] === 'o' && name[1] === 'n':
195
+ parts.push((value) => {
196
+ return pre;
197
+ });
198
+ break;
199
+ default:
200
+ parts.push((value) => {
201
+ let result = pre;
202
+ if (value != null) {
203
+ result += attribute(name, value, isSingleValue);
204
+ }
205
+ return result;
206
+ });
207
+ break;
208
+ }
209
+ } else {
210
+ // AttributePart in the middle of an attribute value or NodePart
211
+ parts.push((value) => {
212
+ let result = pre;
213
+ // TODO: we currently cannot distinguish between NodeParts and AttributeParts before
214
+ if (isObjectLike(value) && value.directiveClass) {
215
+ // NodePart
216
+ const directive = new value.directiveClass();
217
+ result += directive.stringify(...value.values);
218
+ } else if (value != null) {
219
+ result += encodeAttribute(isObjectLike(value) ? JSON.stringify(value) : value);
220
+ }
221
+ return result;
222
+ });
223
+ }
224
+ }
225
+
226
+ // We couldn't correctly parse parts from the template
227
+ if (parts.length !== strings.length - 1) {
228
+ throw {
229
+ name: 'ParseTemplateError',
230
+ message: 'Could not parse parts from template correctly. Parts length has not the expected length.',
231
+ strings,
232
+ templateString: html,
233
+ parts,
234
+ };
235
+ }
236
+
237
+ if (parts.length) {
238
+ const last = parts[parts.length - 1];
239
+ const chunk = html.slice(i);
240
+ parts[parts.length - 1] = (value) => last(value) + chunk;
241
+ } else {
242
+ parts.push(() => html);
243
+ }
244
+ return parts;
245
+ }
246
+
247
+ /**
248
+ * @param {Node[]} childNodes
249
+ * @return {object[]}
250
+ */
251
+ parseParts(childNodes) {
252
+ // we always create a template fragment so that we can start at the root for traversing the node path
253
+ const range = globalThis.document?.createRange();
254
+ range.setStartBefore(childNodes[0]);
255
+ range.setEndAfter(childNodes[childNodes.length - 1]);
256
+ const template = range.cloneContents();
257
+
258
+ const treeWalker = globalThis.document?.createTreeWalker(template, 128);
259
+ let node = treeWalker.currentNode;
260
+
261
+ // this is kinda freaky because the tree walker is shared throughout the recursion - but YOLO
262
+ const getParts = () => {
263
+ const parts = [];
264
+ let currentPart = undefined;
265
+ // search for parts through numbered comment nodes with placeholders
266
+ while ((node = treeWalker.nextNode())) {
267
+ if (/^template-part$/.test(node.data) && currentPart) {
268
+ // start recursion -> add nested parts to previous part
269
+ currentPart.parts = getParts();
270
+ }
271
+ if (/^\/template-part$/.test(node.data)) {
272
+ // end recursion
273
+ return parts;
274
+ }
275
+ if (/^dom-part-\d+$/.test(node.data)) {
276
+ parts.push((currentPart = { type: 'node', path: getNodePath(node), parts: [] }));
277
+ }
278
+ if (/^dom-part-\d+:/.test(node.data)) {
279
+ const [_, attribute] = node.data.split(':');
280
+ const [name, initialValue] = attribute.split('=');
281
+ currentPart = undefined;
282
+ parts.push({ type: 'attribute', path: getNodePath(node), name: name, initialValue });
283
+ }
284
+ if (/^dom-part-\d+\$/.test(node.data)) {
285
+ currentPart = undefined;
286
+ parts.push({ type: 'directive', path: getNodePath(node) });
287
+ }
288
+ }
289
+ return parts;
290
+ };
291
+
292
+ const parts = getParts();
293
+
294
+ // We couldn't correctly parse parts from the template
295
+ if (parts.length !== this.strings.length - 1) {
296
+ throw {
297
+ name: 'ParseTemplateError',
298
+ message:
299
+ 'Could not parse parts from template correctly. Parts length has not the expected length. -> ' +
300
+ JSON.stringify({
301
+ strings: this.strings,
302
+ templateString: this.templateString,
303
+ childNodes,
304
+ template,
305
+ parts,
306
+ }),
307
+ };
308
+ }
309
+
310
+ return parts;
311
+ }
312
+
313
+ /**
314
+ * find interpolations in the given template for nodes and attributes and
315
+ * return a string with placeholders as either comment nodes or named attributes.
316
+ * @returns {string} template with tagged placeholders for values
317
+ */
318
+ get templateString() {
319
+ return createTemplateString(this.strings);
320
+ }
321
+
322
+ toString() {
323
+ let updates = parsedUpdates.get(this.strings);
324
+
325
+ if (!updates) {
326
+ updates = this.parse(this.strings);
327
+ parsedUpdates.set(this.strings, updates);
328
+ }
329
+
330
+ return this.values.length ? this.values.map((value, index) => updates[index](value)).join('') : updates[0]();
331
+ }
332
+ }
@@ -0,0 +1,109 @@
1
+ import { camelToDash, decodeAttribute, encodeAttribute } from '../util/AttributeParser.js';
2
+ import { TemplateResult } from './TemplateResult.js';
3
+ import { convertStringToHTML, convertStringToTemplate } from '../util/DOMHelper.js';
4
+ import { defineDirective, Directive } from '../util/Directive.js';
5
+
6
+ /**
7
+ * Maps a list of classes to an element from an object.
8
+ * The keys will become the classes when the value is truthy.
9
+ * @param {Object.<string, string | boolean | number>} map
10
+ * @returns {string} string of classes joined together
11
+ */
12
+ const classMap = (map) => {
13
+ const classes = [];
14
+ for (const [key, value] of Object.entries(map)) {
15
+ if (value) classes.push(key);
16
+ }
17
+ return classes.join(' ');
18
+ };
19
+
20
+ /**
21
+ * Maps a list of styles to an element from an object.
22
+ * The keys will become the properties and the values will become the values.
23
+ * @param {Object.<string, string | undefined | null>} map
24
+ * @returns {string} string of styles joined together
25
+ */
26
+ const styleMap = (map) => {
27
+ const styles = [];
28
+ for (const [key, value] of Object.entries(map)) {
29
+ if (value) styles.push(`${key}:${value};`);
30
+ }
31
+ return styles.join(' ');
32
+ };
33
+
34
+ /**
35
+ * Renders one of two template parts based on a condition.
36
+ * @param {boolean} condition
37
+ * @param {TemplateResult | string} trueCase
38
+ * @param {TemplateResult | string} [falseCase]
39
+ * @returns {TemplateResult | string}
40
+ */
41
+ const when = (condition, trueCase, falseCase) => {
42
+ return condition ? trueCase : falseCase;
43
+ };
44
+
45
+ /**
46
+ * Chooses and evaluates a template part from a map of cases based on matching the given value to a case.
47
+ * @param {string} value
48
+ * @param {Object.<string, TemplateResult | string>} cases
49
+ * @param {TemplateResult | string} [defaultCase]
50
+ * @returns {TemplateResult | string}
51
+ */
52
+ const choose = (value, cases, defaultCase) => {
53
+ return cases[value] || defaultCase;
54
+ };
55
+
56
+ /**
57
+ * Renders a given string as HTML instead of text
58
+ * @param {string} string
59
+ * @returns {function(): Node | string}
60
+ */
61
+ const unsafeHTML = (string) => {
62
+ if (!globalThis.DOMParser) return () => string;
63
+ const node = convertStringToHTML(string).firstChild;
64
+ const importedNode = globalThis.document?.importNode(node, true);
65
+ return () => importedNode;
66
+ };
67
+
68
+ /**
69
+ * Renders multiple attributes as key="value" pairs from a map of attributes
70
+ */
71
+ export class SpreadAttributesDirective extends Directive {
72
+ /**
73
+ * @param {Object.<string, any>} attributes
74
+ */
75
+ update(attributes) {
76
+ // TODO: what if the attribute was already there..?!
77
+ // TODO: what if the attribute was there from a previous render/update, but not anymore?!
78
+ for (const name of Object.keys(attributes)) {
79
+ const value = attributes[name];
80
+ this.node.setAttribute(
81
+ camelToDash(name),
82
+ encodeAttribute(typeof value !== 'string' ? JSON.stringify(value) : value),
83
+ );
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param {Object.<string, any>} attributes
89
+ * @returns {string}
90
+ */
91
+ stringify(attributes) {
92
+ return Object.keys(attributes)
93
+ .map((name) => {
94
+ let value = attributes[name];
95
+ return `${camelToDash(name)}='${encodeAttribute(
96
+ typeof value !== 'string' ? JSON.stringify(value) : value,
97
+ )}'`;
98
+ })
99
+ .join(' ');
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Renders multiple attributes as key="value" pairs from a map of attributes
105
+ * @param {Object.<string, any>} attributes
106
+ */
107
+ const spreadAttributes = defineDirective(SpreadAttributesDirective);
108
+
109
+ export { classMap, styleMap, when, choose, unsafeHTML, spreadAttributes };
@@ -0,0 +1,12 @@
1
+ import { TemplateResult } from './TemplateResult';
2
+
3
+ /**
4
+ * @param {TemplateStringsArray} strings
5
+ * @param {any[]} values
6
+ * @return {TemplateResult}
7
+ */
8
+ const html = function (strings, ...values) {
9
+ return new TemplateResult(strings, ...values);
10
+ };
11
+
12
+ export { html };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Render a template string into the given DOM node
3
+ * @param {TemplateResult | string} template
4
+ * @param {Element} domNode
5
+ */
6
+ const render = (template, domNode) => {
7
+ template.renderInto(domNode);
8
+ };
9
+
10
+ export { render };
@@ -1,23 +1,48 @@
1
+ /**
2
+ * Tests if a value is of type `object`
3
+ * @param {any} value
4
+ * @returns {boolean}
5
+ */
1
6
  export function isObjectLike(value) {
2
7
  return typeof value == 'object' && value !== null;
3
8
  }
4
9
 
5
- export function isJSON(str) {
10
+ /**
11
+ * Tests if a value can be parsed as JSON
12
+ * @param {any} value
13
+ * @returns {boolean}
14
+ */
15
+ export function isJSON(value) {
6
16
  try {
7
- return JSON.parse(str) && !!str;
17
+ return JSON.parse(value) && !!value;
8
18
  } catch (e) {
9
19
  return false;
10
20
  }
11
21
  }
12
22
 
23
+ /**
24
+ * Tests if a value is `boolean`
25
+ * @param {any} value
26
+ * @returns {boolean}
27
+ */
13
28
  export function isBoolean(value) {
14
29
  return value === 'true' || value === 'false';
15
30
  }
16
31
 
32
+ /**
33
+ * Parses a value to `boolean`
34
+ * @param {any} value
35
+ * @returns {boolean}
36
+ */
17
37
  export function parseBoolean(value) {
18
38
  return value === 'true';
19
39
  }
20
40
 
41
+ /**
42
+ * Tests if a value is of type `string`
43
+ * @param {any} value
44
+ * @returns {boolean}
45
+ */
21
46
  export function isString(value) {
22
47
  return (
23
48
  typeof value === 'string' ||
@@ -25,26 +50,40 @@ export function isString(value) {
25
50
  );
26
51
  }
27
52
 
53
+ /**
54
+ * Tests if a value is of type `number`
55
+ * @param {any} value
56
+ * @returns {boolean}
57
+ */
28
58
  export function isNumber(value) {
29
59
  return new RegExp('^-?(0|0\\.\\d+|[1-9]\\d*(\\.\\d+)?)$').test(value);
30
60
  }
31
61
 
62
+ /**
63
+ * Tests if a value is of type `NaN`
64
+ * @param {any} value
65
+ * @returns {boolean}
66
+ */
32
67
  export function isNaN(value) {
33
68
  return Number.isNaN(value);
34
69
  }
35
70
 
36
71
  /**
37
- * deep compare two values primitive or complex
38
- *
39
- * @param valueA
40
- * @param valueB
72
+ * Compare two values (deep). It compares primitive or complex values with JSON stringify.
73
+ * @param {any} valueA
74
+ * @param {any} valueB
41
75
  * @returns {boolean}
42
76
  */
43
-
44
77
  export function deepEquals(valueA, valueB) {
45
78
  return JSON.stringify(valueA) === JSON.stringify(valueB);
46
79
  }
47
80
 
81
+ /**
82
+ * Parses an attribute value that comes in as a `string` to its corresponding type
83
+ * @param {string} value
84
+ * @param {PropertyOptions} options
85
+ * @returns {string | number | boolean | object | array}
86
+ */
48
87
  export function parseAttribute(value, options = {}) {
49
88
  if (options.parse === false || !isString(value)) {
50
89
  // no-op
@@ -67,7 +106,8 @@ export function parseAttribute(value, options = {}) {
67
106
 
68
107
  /**
69
108
  * Replaces dashed-expression (i.e. some-value) to a camel-cased expression (i.e. someValue)
70
- * @returns string
109
+ * @param {string} string
110
+ * @returns {string}
71
111
  */
72
112
  export function dashToCamel(string) {
73
113
  if (string.indexOf('-') === -1) return string;
@@ -77,44 +117,37 @@ export function dashToCamel(string) {
77
117
 
78
118
  /**
79
119
  * Replaces camel-cased expression (i.e. someValue) to a dashed-expression (i.e. some-value)
80
- * @returns string
120
+ * @param {string} string
121
+ * @returns {string}
81
122
  */
82
123
  export function camelToDash(string) {
83
124
  return string.replace(/([A-Z])/g, '-$1').toLowerCase();
84
125
  }
85
126
 
86
127
  /**
87
- * Decodes an attribute. Can only be encoded with the `encodeAttribute` method which
88
- * is exported from this file.
89
- *
90
- * @param attribute
128
+ * Decodes an attribute.
129
+ * @param {string} attribute
91
130
  * @returns {string}
92
131
  */
93
132
  export function decodeAttribute(attribute) {
94
133
  return `${attribute}`
95
- .replace(/\$lt;/g, '<')
96
- .replace(/\$gt;/g, '>')
97
- .replace(/\$quot;/g, '"')
98
- .replace(/\$apos;/g, "'");
134
+ .replace(/&lt;/g, '<')
135
+ .replace(/&gt;/g, '>')
136
+ .replace(/&quot;/g, '"')
137
+ .replace(/&apos;/g, "'");
99
138
  }
100
139
 
101
140
  /**
102
- * Encodes an attribute. Can only be decoded with the `decodeAttribute` method which
103
- * is exported from this file.
104
- *
105
- * This does not use the standard way to encode an attribute, as the dom parser
106
- * that renders the html would understand and automatically decode it. Which results
107
- * in non sanitized html output.
108
- *
109
- * @param attribute
141
+ * Encodes an attribute.
142
+ * @param {string} attribute
110
143
  * @returns {string}
111
144
  */
112
145
  export function encodeAttribute(attribute) {
113
146
  return `${attribute}`
114
- .replace(/'/g, '$apos;')
115
- .replace(/"/g, '$quot;')
116
- .replace(/</g, '$lt;')
117
- .replace(/>/g, '$gt;')
147
+ .replace(/'/g, '&apos;')
148
+ .replace(/"/g, '&quot;')
149
+ .replace(/</g, '&lt;')
150
+ .replace(/>/g, '&gt;')
118
151
  .replace(/\r\n/g, '\n')
119
152
  .replace(/[\r\n]/g, '\n');
120
153
  }