mi-element 0.7.0 → 0.8.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 CHANGED
@@ -94,6 +94,7 @@ In `./example` you'll find a working sample of a Todo App. Check it out with
94
94
  # Documentation
95
95
 
96
96
  - [element][docs-element] mi-element's lifecycle
97
+ - [render][docs-render] mi-elements html template literal and render function
97
98
  - [controller][docs-controller] adding controllers to mi-element to hook into the lifecycle
98
99
  - [signal][docs-signal] Signals and effect for reactive behavior
99
100
  - [store][docs-store] Manage shared state in an application
@@ -106,6 +107,7 @@ In `./example` you'll find a working sample of a Todo App. Check it out with
106
107
  MIT licensed
107
108
 
108
109
  [docs-element]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/element.md
110
+ [docs-render]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/render.md
109
111
  [docs-controller]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/controller.md
110
112
  [docs-context]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/context.md
111
113
  [docs-signal]: https://github.com/commenthol/mi-element/tree/main/packages/mi-element/docs/signal.md
package/dist/element.js CHANGED
@@ -1,10 +1,12 @@
1
+ import { createSignal } from 'mi-signal';
2
+
1
3
  import { kebabToCamelCase, camelToKebabCase } from './case.js';
2
4
 
3
5
  import { addGlobalStyles } from './styling.js';
4
6
 
5
7
  import { refsBySelector } from './refs.js';
6
8
 
7
- import { createSignal } from 'mi-signal';
9
+ import { toNumber, toJson } from './utils.js';
8
10
 
9
11
  const nameMap = {
10
12
  class: 'className',
@@ -129,18 +131,9 @@ const define = (tagName, elementClass, options) => {
129
131
  elementClass.styles && (elementClass.styles = styles || (usedCssPrefix === cssPrefix ? elementClass.styles : elementClass.styles.replaceAll(`--${usedCssPrefix}-`, cssPrefix))),
130
132
  renderTemplate(elementClass), window.customElements.define(tagName, elementClass);
131
133
  }, renderTemplate = element => {
132
- if (element.template instanceof HTMLTemplateElement) return;
134
+ if (!element.template || element.template instanceof HTMLTemplateElement) return;
133
135
  const el = document.createElement('template');
134
- el.innerHTML = element.template, element.template = el;
135
- }, toJson = any => {
136
- try {
137
- return JSON.parse(any);
138
- } catch {
139
- return;
140
- }
141
- }, convertType = (value, type) => type === Boolean ? null !== value : type === Number ? (any => {
142
- const n = Number(any);
143
- return isNaN(n) ? 0 : n;
144
- })(value) : type === Array ? toJson(value) ?? value.split(',').map(v => v.trim()) : type === Object ? toJson(value) : value;
136
+ el.innerHTML = element.template || '', element.template = el;
137
+ }, convertType = (value, type) => type === Boolean ? null !== value : type === Number ? toNumber(value) : type === Array ? toJson(value) ?? value.split(',').map(v => v.trim()) : type === Object ? toJson(value) : value;
145
138
 
146
139
  export { MiElement, convertType, define };
package/dist/html.js ADDED
@@ -0,0 +1,74 @@
1
+ import { toJson } from './utils.js';
2
+
3
+ const globalRenderCache = new class {
4
+ cnt=0;
5
+ map=new Map;
6
+ cache=new WeakMap;
7
+ _inc() {
8
+ return this.cnt = 268435455 & ++this.cnt, this.cnt;
9
+ }
10
+ clear() {
11
+ this.cnt = 0, this.map.clear();
12
+ }
13
+ set(value) {
14
+ const key = '__rc:' + this._inc().toString(36), ref = {};
15
+ return this.map.set(key, ref), this.cache.set(ref, value), key;
16
+ }
17
+ get(key) {
18
+ const ref = this.map.get(key);
19
+ return this.map.delete(key), this.cache.get(ref);
20
+ }
21
+ };
22
+
23
+ class UnsafeHtml extends String {}
24
+
25
+ const unsafeHtml = str => new UnsafeHtml(str), escMap = {
26
+ '&': '&',
27
+ '<': '&lt;',
28
+ '>': '&gt;',
29
+ '"': '&quot;',
30
+ "'": '&#39;'
31
+ }, esc = string => string.replace(/[&<>"']/g, tag => escMap[tag]), escHtml = string => string instanceof UnsafeHtml ? string : unsafeHtml(esc('' + string)), escValue = any => {
32
+ if (any instanceof UnsafeHtml) return any;
33
+ if ([ 'object', 'function' ].includes(typeof any)) {
34
+ const key = globalRenderCache.set(any);
35
+ return unsafeHtml(key);
36
+ }
37
+ return unsafeHtml(esc('' + any));
38
+ }, html = (strings, ...values) => unsafeHtml(String.raw({
39
+ raw: strings
40
+ }, ...values.map(val => Array.isArray(val) ? val.map(escValue).join('') : escValue(val))));
41
+
42
+ function render(node, template, handlers = {}) {
43
+ const refs = {}, div = document.createElement('div');
44
+ div.innerHTML = template.toString();
45
+ for (let child of Array.from(div.children)) renderAttrs(child, handlers, refs),
46
+ node.appendChild(child);
47
+ return refs;
48
+ }
49
+
50
+ function renderAttrs(node, handlers = {}, refs = {}) {
51
+ if (node.nodeType === Node.ELEMENT_NODE) for (let attr of node.attributes) {
52
+ const startsWith = attr.name[0], name = attr.name.slice(1);
53
+ let rm = 0;
54
+ if ('?' === startsWith) toJson(attr.value) ? node.setAttribute(name, '') : node.removeAttribute(name),
55
+ rm = 1; else if ('...' === attr.name) {
56
+ const obj = globalRenderCache.get(attr.value);
57
+ if (obj && 'object' == typeof obj) for (const [k, v] of Object.entries(obj)) node[k] = v;
58
+ rm = 1;
59
+ } else if ('.' === startsWith) node[name] = globalRenderCache.get(attr.value) ?? attr.value,
60
+ rm = 1; else if ('@' === startsWith) {
61
+ const handlerName = attr.value, fn = globalRenderCache.get(handlerName);
62
+ fn ? node.addEventListener(name, e => fn(e)) : 'function' == typeof handlers[handlerName] && node.addEventListener(name, e => handlers[handlerName](e)),
63
+ rm = 1;
64
+ } else 'ref' === attr.name && (refs[attr.value] = node, rm = 1);
65
+ rm && requestAnimationFrame(() => {
66
+ node.removeAttribute(attr.name);
67
+ });
68
+ }
69
+ if (!node.children?.length || customElements.get(node.localName)) return refs;
70
+ for (let child of Array.from(node.children)) renderAttrs(child, handlers, refs);
71
+ return refs;
72
+ }
73
+
74
+ export { escHtml, globalRenderCache, html, render, renderAttrs, unsafeHtml };
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ export { ContextConsumer, ContextProvider, ContextRequestEvent } from './context
2
2
 
3
3
  export { MiElement, convertType, define } from './element.js';
4
4
 
5
- export { escHtml, html, unsafeHtml } from './escape.js';
5
+ export { escHtml, html, render, renderAttrs, unsafeHtml } from './html.js';
6
6
 
7
7
  export { refsBySelector } from './refs.js';
8
8
 
package/dist/utils.js ADDED
@@ -0,0 +1,12 @@
1
+ const toNumber = any => {
2
+ const n = Number(any);
3
+ return isNaN(n) ? 0 : n;
4
+ }, toJson = any => {
5
+ try {
6
+ return JSON.parse(any);
7
+ } catch {
8
+ return;
9
+ }
10
+ };
11
+
12
+ export { toJson, toNumber };
package/docs/render.md ADDED
@@ -0,0 +1,357 @@
1
+ **Table of contents**
2
+
3
+ <!-- !toc (minlevel=2) -->
4
+
5
+ - [html Tagged Template Literal](#html-tagged-template-literal)
6
+ - [Basic Usage](#basic-usage)
7
+ - [Working with Arrays](#working-with-arrays)
8
+ - [Nested Templates](#nested-templates)
9
+ - [Unsafe HTML](#unsafe-html)
10
+ - [render() Function](#render-function)
11
+ - [Signature](#signature)
12
+ - [Basic Example](#basic-example)
13
+ - [Special Attributes](#special-attributes)
14
+ - [Boolean Attributes (`?attr`)](#boolean-attributes-attr)
15
+ - [Property Binding (`.prop`)](#property-binding-prop)
16
+ - [Event Listeners (`@event`)](#event-listeners-event)
17
+ - [Element References (`ref`)](#element-references-ref)
18
+ - [Spread Properties (`...`)](#spread-properties-)
19
+ - [Complete Example](#complete-example)
20
+ - [Security](#security)
21
+ - [Best Practices](#best-practices)
22
+ - [Custom Elements](#custom-elements)
23
+
24
+ <!-- toc! -->
25
+
26
+ # HTML Templating and Rendering
27
+
28
+ MiElement offers a lightweight templating system for properly escaping HTML to
29
+ prevent XSS attacks. The `html` tagged template literal combined with the
30
+ `render()` function provides a safe and convenient way to create dynamic HTML
31
+ with event handlers, property bindings, and element references.
32
+
33
+ ## html Tagged Template Literal
34
+
35
+ The `html` function is a tagged template literal that automatically escapes all
36
+ interpolated values to prevent XSS attacks.
37
+
38
+ ### Basic Usage
39
+
40
+ ```js
41
+ import { html } from '@mi-element/mi-element'
42
+
43
+ // Simple text escaping
44
+ const userInput = '<script>alert("xss")</script>'
45
+ const safe = html`<div>${userInput}</div>`
46
+ // Result: <div>&lt;script&gt;alert("xss")&lt;/script&gt;</div>
47
+ ```
48
+
49
+ ### Working with Arrays
50
+
51
+ Arrays are automatically joined and each item is escaped:
52
+
53
+ ```js
54
+ const items = ['Apple', 'Banana', 'Cherry']
55
+ const list = html`<ul>
56
+ ${items.map((item) => html`<li>${item}</li>`)}
57
+ </ul>`
58
+ ```
59
+
60
+ ### Nested Templates
61
+
62
+ HTML templates can be nested safely without double-escaping:
63
+
64
+ ```js
65
+ const header = html`<h1>${'<Title>'}</h1>`
66
+ const page = html`
67
+ <div>
68
+ ${header}
69
+ <p>${'<content>'}</p>
70
+ </div>
71
+ `
72
+ // The header content remains escaped, not double-escaped
73
+ ```
74
+
75
+ ### Unsafe HTML
76
+
77
+ When you need to render raw HTML (use with caution):
78
+
79
+ ```js
80
+ import { unsafeHtml } from '@mi-element/mi-element'
81
+
82
+ const trustedHtml = '<strong>Bold</strong>'
83
+ const template = html`<div>${unsafeHtml(trustedHtml)}</div>`
84
+ // Result: <div><strong>Bold</strong></div>
85
+ ```
86
+
87
+ ## render() Function
88
+
89
+ The `render()` function takes an HTML template and renders it into a DOM node,
90
+ with support for special attributes for event handling, property binding, and
91
+ element references.
92
+
93
+ ### Signature
94
+
95
+ ```js
96
+ render(node, template, (handlers = {}))
97
+ ```
98
+
99
+ - `node`: Target DOM element to render into
100
+ - `template`: HTML template string (typically from `html` tagged template)
101
+ - `handlers`: Optional object containing event handler functions or HTMLElement
102
+ for method lookup
103
+ - **Returns**: Object with collected element references
104
+
105
+ ### Basic Example
106
+
107
+ ```js
108
+ import { html, render } from '@mi-element/mi-element'
109
+
110
+ const container = document.querySelector('#app')
111
+ const template = html`
112
+ <div>
113
+ <h1>Hello World</h1>
114
+ <p>Welcome to MiElement</p>
115
+ </div>
116
+ `
117
+
118
+ render(container, template)
119
+ ```
120
+
121
+ ## Special Attributes
122
+
123
+ Special attributes provide powerful features for dynamic behavior. All special
124
+ attributes are removed from the DOM after processing.
125
+
126
+ ### Boolean Attributes (`?attr`)
127
+
128
+ Use the `?` prefix for boolean attributes:
129
+
130
+ ```js
131
+ const isDisabled = true
132
+ const template = html`
133
+ <input ?disabled=${isDisabled} />
134
+ <button ?hidden=${false}>Click</button>
135
+ `
136
+
137
+ render(container, template)
138
+ // Result: <input disabled=""> <button>Click</button>
139
+ ```
140
+
141
+ ### Property Binding (`.prop`)
142
+
143
+ Use the `.` prefix to set properties directly on DOM elements (not attributes):
144
+
145
+ ```js
146
+ const inputValue = 'Hello'
147
+ const userData = { name: 'John', age: 30 }
148
+
149
+ const template = html`
150
+ <input .value=${inputValue} />
151
+ <my-component .data=${userData}></my-component>
152
+ `
153
+
154
+ render(container, template)
155
+ // The input.value property is set, but not visible as an attribute
156
+ ```
157
+
158
+ **Note**: Use kebab-case for property names - they will be automatically
159
+ converted to camelCase:
160
+
161
+ ```js
162
+ html`<div .some-property=${'value'}></div>`
163
+ // Sets element.someProperty = 'value'
164
+ ```
165
+
166
+ ### Event Listeners (`@event`)
167
+
168
+ Use the `@` prefix for event listeners:
169
+
170
+ #### Inline Functions
171
+
172
+ ```js
173
+ const template = html`
174
+ <button @click=${(e) => console.log('Clicked!', e)}>Click Me</button>
175
+ `
176
+
177
+ render(container, template)
178
+ ```
179
+
180
+ #### Named Handlers
181
+
182
+ ```js
183
+ const handlers = {
184
+ handleClick(e) {
185
+ console.log('Button clicked:', e.target)
186
+ },
187
+ handleInput(e) {
188
+ console.log('Input value:', e.target.value)
189
+ }
190
+ }
191
+
192
+ const template = html`
193
+ <button @click="handleClick">Click</button>
194
+ <input @input="handleInput" />
195
+ `
196
+
197
+ render(container, template, handlers)
198
+ ```
199
+
200
+ #### Using HTMLElement Methods
201
+
202
+ ```js
203
+ class MyComponent extends HTMLElement {
204
+ handleClick(e) {
205
+ console.log('Clicked in component')
206
+ }
207
+
208
+ connectedCallback() {
209
+ const template = html` <button @click="handleClick">Click</button> `
210
+ render(this, template, this)
211
+ }
212
+ }
213
+ ```
214
+
215
+ ### Element References (`ref`)
216
+
217
+ Collect references to rendered elements:
218
+
219
+ ```js
220
+ const template = html`
221
+ <div>
222
+ <input ref="nameInput" type="text" />
223
+ <button ref="submitBtn">Submit</button>
224
+ </div>
225
+ `
226
+
227
+ const refs = render(container, template)
228
+ // refs.nameInput -> the input element
229
+ // refs.submitBtn -> the button element
230
+
231
+ refs.nameInput.focus()
232
+ refs.submitBtn.addEventListener('click', () => {
233
+ console.log(refs.nameInput.value)
234
+ })
235
+ ```
236
+
237
+ ### Spread Properties (`...`)
238
+
239
+ Spread multiple properties from an object:
240
+
241
+ ```js
242
+ const inputProps = {
243
+ value: 'Default text',
244
+ disabled: true,
245
+ placeholder: 'Enter text'
246
+ }
247
+
248
+ const template = html`<input ...=${inputProps} />`
249
+
250
+ render(container, template)
251
+ // Sets all properties: value, disabled, and placeholder
252
+ ```
253
+
254
+ ## Complete Example
255
+
256
+ ```js
257
+ import { html, render, MiElement, define } from '@mi-element/mi-element'
258
+ import { MiElement } from '@mi-element/mi-element'
259
+
260
+ class TodoList extends MiElement {
261
+ todos = [
262
+ { id: 1, text: 'Learn MiElement', done: false },
263
+ { id: 2, text: 'Build something', done: false }
264
+ ]
265
+
266
+ toggleTodo(id) {
267
+ const todo = this.todos.find((t) => t.id === id)
268
+ if (todo) todo.done = !todo.done
269
+ this.requestUpdate()
270
+ }
271
+
272
+ // first time render
273
+ render() {
274
+ const template = html`
275
+ <div>
276
+ <h2>My Todos</h2>
277
+ <ul ref="list"></ul>
278
+ </div>
279
+ `
280
+ this.refs = render(this.renderRoot, template)
281
+ }
282
+
283
+ // any updates
284
+ update() {
285
+ const template = this.todos.map(
286
+ (todo) => html`
287
+ <li>
288
+ <input
289
+ type="checkbox"
290
+ ?checked=${todo.done}
291
+ @change=${() => this.toggleTodo(todo.id)}
292
+ />
293
+ <span>${todo.text}</span>
294
+ </li>
295
+ `
296
+ )
297
+ // clear all children first
298
+ this.refs.list.innerHTML = ''
299
+ // then rerender
300
+ render(this.refs.list, template)
301
+ }
302
+ }
303
+
304
+ define('todo-list', TodoList)
305
+ ```
306
+
307
+ ## Security
308
+
309
+ The `html` tagged template automatically escapes all values to prevent XSS
310
+ attacks:
311
+
312
+ - Strings are HTML-escaped
313
+ - Objects and functions are stored in a temporary cache and referenced by key
314
+ - Only use `unsafeHtml()` when you have trusted HTML content
315
+
316
+ ```js
317
+ // Safe - user input is escaped
318
+ const userInput = '<script>alert("xss")</script>'
319
+ html`<div>${userInput}</div>`
320
+ // Result: <div>&lt;script&gt;alert("xss")&lt;/script&gt</div>;
321
+
322
+ // Unsafe - only use with trusted content
323
+ const trustedHtml = '<strong>Safe HTML</strong>'
324
+ html`<div>${unsafeHtml(trustedHtml)}</div>`
325
+ ```
326
+
327
+ ## Best Practices
328
+
329
+ 1. **Always use `html` for dynamic content** to ensure proper escaping
330
+ 2. **Use kebab-case** for all attribute and event names
331
+ 3. **Collect refs** instead of using `querySelector` when possible
332
+ 4. **Avoid `unsafeHtml`** unless absolutely necessary with trusted content
333
+ 5. **Use property binding** (`.prop`) for complex data structures
334
+ 6. **Leverage event delegation** for dynamic lists with many items
335
+ 7. **Keep handlers object** or use class methods for better organization
336
+
337
+ ## Custom Elements
338
+
339
+ The render system automatically skips processing children of custom elements,
340
+ allowing them to manage their own content:
341
+
342
+ ```js
343
+ class MyElement extends HTMLElement {
344
+ connectedCallback() {
345
+ // This element controls its own rendering
346
+ this.innerHTML = `<input ref="inside" />`
347
+ }
348
+ }
349
+
350
+ customElements.define('my-element', MyElement)
351
+
352
+ // The render function will process my-element's attributes
353
+ // but won't process its children
354
+ const template = html`
355
+ <my-element .data=${{ test: true }} ref="custom"></my-element>
356
+ `
357
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mi-element",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Build lightweight reactive micro web-components",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/commenthol/mi-element/tree/main/packages/mi-element#readme",
@@ -40,10 +40,10 @@
40
40
  "types": "./types/context.d.ts",
41
41
  "default": "./dist/context.js"
42
42
  },
43
- "./escape": {
44
- "development": "./src/escape.js",
45
- "types": "./types/escape.d.ts",
46
- "default": "./dist/escape.js"
43
+ "./html": {
44
+ "development": "./src/html.js",
45
+ "types": "./types/html.d.ts",
46
+ "default": "./dist/html.js"
47
47
  },
48
48
  "./refs": {
49
49
  "development": "./src/refs.js",
@@ -69,7 +69,7 @@
69
69
  "types"
70
70
  ],
71
71
  "dependencies": {
72
- "mi-signal": "0.7.0"
72
+ "mi-signal": "0.8.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@eslint/js": "^9.39.2",
@@ -90,7 +90,7 @@
90
90
  "typescript": "^5.9.3",
91
91
  "vite": "^7.3.0",
92
92
  "vitest": "^4.0.16",
93
- "mi-html": "0.7.0"
93
+ "mi-html": "0.8.0"
94
94
  },
95
95
  "scripts": {
96
96
  "all": "npm-run-all pretty lint test build types",
package/src/element.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { createSignal } from 'mi-signal'
1
2
  import { kebabToCamelCase, camelToKebabCase } from './case.js'
2
3
  import { addGlobalStyles } from './styling.js'
3
4
  import { refsBySelector } from './refs.js'
4
- import { createSignal } from 'mi-signal'
5
+ import { toNumber, toJson } from './utils.js'
5
6
 
6
7
  /**
7
8
  * Mapping of attribute names to property names
@@ -376,27 +377,14 @@ export const define = (tagName, elementClass, options) => {
376
377
  * @param {typeof MiElement} element
377
378
  */
378
379
  const renderTemplate = (element) => {
379
- if (element.template instanceof HTMLTemplateElement) {
380
+ if (!element.template || element.template instanceof HTMLTemplateElement) {
380
381
  return
381
382
  }
382
383
  const el = document.createElement('template')
383
- el.innerHTML = element.template
384
+ el.innerHTML = element.template || ''
384
385
  element.template = el
385
386
  }
386
387
 
387
- const toNumber = (any) => {
388
- const n = Number(any)
389
- return isNaN(n) ? 0 : n
390
- }
391
-
392
- const toJson = (any) => {
393
- try {
394
- return JSON.parse(any)
395
- } catch {
396
- return
397
- }
398
- }
399
-
400
388
  /**
401
389
  * convert a attribute string value to typed value
402
390
  * @param {string} value
package/src/html.js ADDED
@@ -0,0 +1,208 @@
1
+ import { toJson } from './utils.js'
2
+
3
+ /**
4
+ * A cache for rendering values to avoid keeping them in memory too long
5
+ */
6
+ class RenderCache {
7
+ cnt = 0
8
+ map = new Map()
9
+ cache = new WeakMap()
10
+
11
+ _inc() {
12
+ this.cnt = ++this.cnt & 0xfffffff
13
+ return this.cnt
14
+ }
15
+
16
+ clear() {
17
+ this.cnt = 0
18
+ this.map.clear()
19
+ }
20
+
21
+ set(value) {
22
+ const key = '__rc:' + this._inc().toString(36)
23
+ const ref = {}
24
+ this.map.set(key, ref)
25
+ this.cache.set(ref, value)
26
+ return key
27
+ }
28
+
29
+ get(key) {
30
+ const ref = this.map.get(key)
31
+ this.map.delete(key)
32
+ return this.cache.get(ref)
33
+ }
34
+ }
35
+
36
+ export const globalRenderCache = new RenderCache()
37
+
38
+ /**
39
+ * A helper class to avoid double escaping of HTML strings
40
+ */
41
+ class UnsafeHtml extends String {}
42
+
43
+ /**
44
+ * tag a string as html for not to be escaped
45
+ * @param {string} str
46
+ * @returns {string}
47
+ */
48
+ // @ts-expect-error
49
+ export const unsafeHtml = (str) => new UnsafeHtml(str)
50
+
51
+ const escMap = {
52
+ '&': '&amp;',
53
+ '<': '&lt;',
54
+ '>': '&gt;',
55
+ '"': '&quot;', // need the quotes for escaping attribute values
56
+ "'": '&#39;'
57
+ }
58
+
59
+ const esc = (string) => string.replace(/[&<>"']/g, (tag) => escMap[tag])
60
+
61
+ /**
62
+ * escape HTML and prevent double escaping of '&'
63
+ * @param {string} string - which requires escaping
64
+ * @returns {string} escaped string
65
+ * @example
66
+ * escapeHTML('<h1>"One" & 'Two' &amp; Works</h1>')
67
+ * //> &lt;h1&gt;&quot;One&quot; &amp; &#39;Two&#39; &amp; Works&lt;/h1&gt;
68
+ */
69
+ export const escHtml = (string) =>
70
+ // @ts-expect-error
71
+ string instanceof UnsafeHtml ? string : unsafeHtml(esc('' + string))
72
+
73
+ /**
74
+ * escape any value for HTML context; objects and functions are stored in the render cache
75
+ * @param {any} any
76
+ * @returns {string}
77
+ */
78
+ const escValue = (any) => {
79
+ if (any instanceof UnsafeHtml) {
80
+ // @ts-expect-error
81
+ return any
82
+ }
83
+ if (['object', 'function'].includes(typeof any)) {
84
+ const key = globalRenderCache.set(any)
85
+ return unsafeHtml(key)
86
+ }
87
+ return unsafeHtml(esc('' + any))
88
+ }
89
+
90
+ /**
91
+ * template literal to HTML escape all values preventing XSS;
92
+ * arrays will be escaped and joined
93
+ * @param {TemplateStringsArray} strings
94
+ * @param {...any} values
95
+ * @returns {string}
96
+ * @example
97
+ * const list = html`<ul>${['<foo', 'bar>'].map(item => html`<li>${item}</li>`)}</ul>`
98
+ * // '<ul><li>&lt;foo</li><li>bar&gt;</li></ul>'
99
+ */
100
+ export const html = (strings, ...values) =>
101
+ unsafeHtml(
102
+ String.raw(
103
+ { raw: strings },
104
+ ...values.map((val) =>
105
+ Array.isArray(val) ? val.map(escValue).join('') : escValue(val)
106
+ )
107
+ )
108
+ )
109
+
110
+ /**
111
+ * render HTML template into given node with support for special attributes
112
+ *
113
+ * @param {Element} node to append rendered content
114
+ * @param {string|UnsafeHtml} template HTML template string
115
+ * @param {Record<string, Function>|HTMLElement} [handlers={}] event handlers or HTMLElement for method lookup
116
+ * @returns {Record<string, Element>} references collected
117
+ */
118
+ export function render(node, template, handlers = {}) {
119
+ const refs = {}
120
+ const div = document.createElement('div')
121
+ div.innerHTML = template.toString()
122
+ // don't understand why `for (let child of div.children)` does not work here
123
+ for (let child of Array.from(div.children)) {
124
+ // @ts-expect-error
125
+ renderAttrs(child, handlers, refs)
126
+ node.appendChild(child)
127
+ }
128
+ // @ts-expect-error
129
+ return refs
130
+ }
131
+
132
+ /**
133
+ * Post-processing of rendered nodes to handle special attributes:
134
+ *
135
+ * - `?attr=${boolean}` -> boolean attribute
136
+ * - `.prop=${objectOrAnyValue}` -> property binding for objects or any value
137
+ * - `...=${object}` -> spread properties from object
138
+ * - `@event=${(e) => {}}` -> event listener with templated inline function
139
+ * - `@event="handlerName"` -> event listener using handler name from handlers object
140
+ * - `ref="refName"` -> element reference collected and returned
141
+ *
142
+ * NOTE: For all attributes and event names always use kebab-case. For properties it will be converted to camelCase.
143
+ * Attributes starting with `?`, `@`, or `.` are removed from DOM after processing
144
+ *
145
+ * @param {Element} node to append rendered content
146
+ * @param {Record<string, Function>|HTMLElement} [handlers={}] event handlers or HTMLElement for method lookup
147
+ * @param {Record<string, Element>} [refs={}] collected references
148
+ * @returns {Record<string, Element>} references collected
149
+ */
150
+ export function renderAttrs(node, handlers = {}, refs = {}) {
151
+ if (node.nodeType === Node.ELEMENT_NODE) {
152
+ for (let attr of node.attributes) {
153
+ const startsWith = attr.name[0]
154
+ const name = attr.name.slice(1)
155
+ let rm = 0
156
+ if (startsWith === '?') {
157
+ // boolean attributes
158
+ if (toJson(attr.value)) {
159
+ node.setAttribute(name, '')
160
+ } else {
161
+ node.removeAttribute(name)
162
+ }
163
+ rm = 1
164
+ } else if (attr.name === '...') {
165
+ // spread attribute
166
+ const obj = globalRenderCache.get(attr.value)
167
+ if (obj && typeof obj === 'object') {
168
+ for (const [k, v] of Object.entries(obj)) {
169
+ node[k] = v
170
+ }
171
+ }
172
+ rm = 1
173
+ } else if (startsWith === '.') {
174
+ // property binding
175
+ node[name] = globalRenderCache.get(attr.value) ?? attr.value
176
+ rm = 1
177
+ } else if (startsWith === '@') {
178
+ // event listener
179
+ const handlerName = attr.value
180
+ const fn = globalRenderCache.get(handlerName)
181
+ if (fn) {
182
+ node.addEventListener(name, (e) => fn(e))
183
+ } else if (typeof handlers[handlerName] === 'function') {
184
+ node.addEventListener(name, (e) => handlers[handlerName](e))
185
+ }
186
+ rm = 1
187
+ } else if (attr.name === 'ref') {
188
+ // element reference - remove as well to prevent collection by other processors
189
+ const refName = attr.value
190
+ refs[refName] = node
191
+ rm = 1
192
+ }
193
+ if (rm) {
194
+ requestAnimationFrame(() => {
195
+ node.removeAttribute(attr.name)
196
+ })
197
+ }
198
+ }
199
+ }
200
+ // early abort if no children or custom element
201
+ if (!node.children?.length || customElements.get(node.localName)) {
202
+ return refs
203
+ }
204
+ for (let child of Array.from(node.children)) {
205
+ renderAttrs(child, handlers, refs)
206
+ }
207
+ return refs
208
+ }
package/src/index.js CHANGED
@@ -10,7 +10,7 @@ export {
10
10
  * @typedef {import('./element.js').HostController} HostController
11
11
  */
12
12
  export { MiElement, convertType, define } from './element.js'
13
- export { unsafeHtml, html, escHtml } from './escape.js'
13
+ export { unsafeHtml, html, escHtml, render, renderAttrs } from './html.js'
14
14
  export { refsBySelector } from './refs.js'
15
15
  /**
16
16
  * @typedef {import('./store.js').Action} Action
package/src/utils.js ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * convert to number safely
3
+ * @param {*} any
4
+ * @returns {number} number or 0 if NaN
5
+ */
6
+ export const toNumber = (any) => {
7
+ const n = Number(any)
8
+ return isNaN(n) ? 0 : n
9
+ }
10
+
11
+ /**
12
+ * safely parse JSON
13
+ * @param {string} any
14
+ * @returns {any|undefined} parsed object or undefined on error
15
+ */
16
+ export const toJson = (any) => {
17
+ try {
18
+ return JSON.parse(any)
19
+ } catch {
20
+ return
21
+ }
22
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * render HTML template into given node with support for special attributes
3
+ *
4
+ * @param {Element} node to append rendered content
5
+ * @param {string|UnsafeHtml} template HTML template string
6
+ * @param {Record<string, Function>|HTMLElement} [handlers={}] event handlers or HTMLElement for method lookup
7
+ * @returns {Record<string, Element>} references collected
8
+ */
9
+ export function render(node: Element, template: string | UnsafeHtml, handlers?: Record<string, Function> | HTMLElement): Record<string, Element>;
10
+ /**
11
+ * Post-processing of rendered nodes to handle special attributes:
12
+ *
13
+ * - `?attr=${boolean}` -> boolean attribute
14
+ * - `.prop=${objectOrAnyValue}` -> property binding for objects or any value
15
+ * - `...=${object}` -> spread properties from object
16
+ * - `@event=${(e) => {}}` -> event listener with templated inline function
17
+ * - `@event="handlerName"` -> event listener using handler name from handlers object
18
+ * - `ref="refName"` -> element reference collected and returned
19
+ *
20
+ * NOTE: For all attributes and event names always use kebab-case. For properties it will be converted to camelCase.
21
+ * Attributes starting with `?`, `@`, or `.` are removed from DOM after processing
22
+ *
23
+ * @param {Element} node to append rendered content
24
+ * @param {Record<string, Function>|HTMLElement} [handlers={}] event handlers or HTMLElement for method lookup
25
+ * @param {Record<string, Element>} [refs={}] collected references
26
+ * @returns {Record<string, Element>} references collected
27
+ */
28
+ export function renderAttrs(node: Element, handlers?: Record<string, Function> | HTMLElement, refs?: Record<string, Element>): Record<string, Element>;
29
+ export const globalRenderCache: RenderCache;
30
+ export function unsafeHtml(str: string): string;
31
+ export function escHtml(string: string): string;
32
+ export function html(strings: TemplateStringsArray, ...values: any[]): string;
33
+ /**
34
+ * A helper class to avoid double escaping of HTML strings
35
+ */
36
+ declare class UnsafeHtml extends String {
37
+ }
38
+ /**
39
+ * A cache for rendering values to avoid keeping them in memory too long
40
+ */
41
+ declare class RenderCache {
42
+ cnt: number;
43
+ map: Map<any, any>;
44
+ cache: WeakMap<WeakKey, any>;
45
+ _inc(): number;
46
+ clear(): void;
47
+ set(value: any): string;
48
+ get(key: any): any;
49
+ }
50
+ export {};
package/types/index.d.ts CHANGED
@@ -6,5 +6,5 @@ export type HostController = import("./element.js").HostController;
6
6
  export type Action = import("./store.js").Action;
7
7
  export { ContextConsumer, ContextProvider, ContextRequestEvent } from "./context.js";
8
8
  export { MiElement, convertType, define } from "./element.js";
9
- export { unsafeHtml, html, escHtml } from "./escape.js";
9
+ export { unsafeHtml, html, escHtml, render, renderAttrs } from "./html.js";
10
10
  export { classNames, styleMap, addGlobalStyles, css } from "./styling.js";
@@ -0,0 +1,2 @@
1
+ export function toNumber(any: any): number;
2
+ export function toJson(any: string): any | undefined;
package/dist/escape.js DELETED
@@ -1,20 +0,0 @@
1
- class UnsafeHtml extends String {}
2
-
3
- const unsafeHtml = str => new UnsafeHtml(str), escMap = {
4
- '&': '&amp;',
5
- '<': '&lt;',
6
- '>': '&gt;'
7
- };
8
-
9
- let esc = string => string.replace(/&amp;/g, '&').replace(/[&<>]/g, tag => escMap[tag]);
10
-
11
- 'undefined' != typeof document && (esc = string => {
12
- const div = document.createElement('div');
13
- return div.textContent = string, div.innerHTML;
14
- });
15
-
16
- const escHtml = string => string instanceof UnsafeHtml ? string : unsafeHtml(esc('' + string)), html = (strings, ...values) => unsafeHtml(String.raw({
17
- raw: strings
18
- }, ...values.map(val => Array.isArray(val) ? val.map(escHtml).join('') : escHtml(val))));
19
-
20
- export { escHtml, html, unsafeHtml };
package/src/escape.js DELETED
@@ -1,60 +0,0 @@
1
- class UnsafeHtml extends String {}
2
-
3
- /**
4
- * tag a string as html for not to be escaped
5
- * @param {string} str
6
- * @returns {string}
7
- */
8
- // @ts-expect-error
9
- export const unsafeHtml = (str) => new UnsafeHtml(str)
10
-
11
- const escMap = {
12
- '&': '&amp;',
13
- '<': '&lt;',
14
- '>': '&gt;'
15
- }
16
-
17
- let esc = (string) => {
18
- return string.replace(/&amp;/g, '&').replace(/[&<>]/g, (tag) => escMap[tag])
19
- }
20
-
21
- if (typeof document !== 'undefined') {
22
- // in browser environment, use DOM to escape
23
- esc = (string) => {
24
- const div = document.createElement('div')
25
- div.textContent = string
26
- return div.innerHTML
27
- }
28
- }
29
-
30
- /**
31
- * escape HTML and prevent double escaping of '&'
32
- * @param {string} string - which requires escaping
33
- * @returns {string} escaped string
34
- * @example
35
- * escapeHTML('<h1>"One" & 'Two' &amp; Works</h1>')
36
- * //> &lt;h1&gt;&quot;One&quot; &amp; &#39;Two&#39; &amp; Works&lt;/h1&gt;
37
- */
38
- export const escHtml = (string) =>
39
- // @ts-expect-error
40
- string instanceof UnsafeHtml ? string : unsafeHtml(esc('' + string))
41
-
42
- /**
43
- * template literal to HTML escape all values preventing XSS;
44
- * arrays will be escaped and joined
45
- * @param {TemplateStringsArray} strings
46
- * @param {...any} values
47
- * @returns {string}
48
- * @example
49
- * const list = html`<ul>${['<foo', 'bar>'].map(item => html`<li>${item}</li>`)}</ul>`
50
- * // '<ul><li>&lt;foo</li><li>bar&gt;</li></ul>'
51
- */
52
- export const html = (strings, ...values) =>
53
- unsafeHtml(
54
- String.raw(
55
- { raw: strings },
56
- ...values.map((val) =>
57
- Array.isArray(val) ? val.map(escHtml).join('') : escHtml(val)
58
- )
59
- )
60
- )
package/types/escape.d.ts DELETED
@@ -1,3 +0,0 @@
1
- export function unsafeHtml(str: string): string;
2
- export function escHtml(string: string): string;
3
- export function html(strings: TemplateStringsArray, ...values: any[]): string;