@massimo-cassandro/minimo 1.10.1 → 1.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massimo-cassandro/minimo",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Light css framework & utilities",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -31,6 +31,7 @@
31
31
  "url": "git+https://github.com/massimo-cassandro/minimo.git"
32
32
  },
33
33
  "scripts": {
34
+ "test": "node --test \"test/**/*.test.mjs\"",
34
35
  "UPD-version": "npx update-version # --config=./dev-utilities.config.mjs",
35
36
  "npm-publish": "npm publish",
36
37
  "upd@m": "npx upd@m",
@@ -68,6 +69,7 @@
68
69
  "devDependencies": {
69
70
  "@massimo-cassandro/eslint-config": "^3.0.11",
70
71
  "@massimo-cassandro/stylelint-config": "^2.3.11",
72
+ "jsdom": "^29.1.1",
71
73
  "style-dictionary": "^5.5.0"
72
74
  },
73
75
  "dependencies": {
@@ -19,6 +19,14 @@ export function getCookie(name) {
19
19
  * @param {string} params.value
20
20
  * @param {string|null} [params.path=null]
21
21
  * @param {number|null} [params.expire=null] - Max-Age in seconds
22
+ * @example
23
+ * // Cookie scoped to the current page only, lasting 15 days
24
+ * setCookie({
25
+ * name: 'cookieName',
26
+ * value: 'cookieValue',
27
+ * path: window.location.pathname,
28
+ * expire: 15 * 24 * 60 * 60 // 15 days
29
+ * });
22
30
  */
23
31
  export function setCookie({name, value, path = null, expire = null}) {
24
32
  document.cookie = `${name}=${value}` +
@@ -13,6 +13,9 @@ import { parseDomString } from './parseDomString.js';
13
13
  * @property {string | null} [id] - Unique element ID.
14
14
  * @property {[string, *] | [string, *][] | Object<string, *>} [attrs] - Attributes: a `[name, value]` pair, array of pairs, or `{name: value}` object.
15
15
  * @property {string | number | Function | Element | DomBuilderItem[] | null} [content] - Element content.
16
+ * A string or number is set as plain text (`textContent`) unless it contains `<`, in which case it is
17
+ * treated as markup: sanitized and inserted via the native Sanitizer API (`Element.setHTML`) where
18
+ * supported, falling back to raw `innerHTML` on browsers without it.
16
19
  * @property {boolean} [condition=true] - When false, the element is skipped.
17
20
  * @property {function(HTMLElement): void} [callback] - Callback invoked after the element is created.
18
21
  * @property {DomBuilderItem[]} [children] - Configuration array for child elements.
@@ -35,7 +38,7 @@ import { parseDomString } from './parseDomString.js';
35
38
  * className: 'xxx' | ['class1', 'class2'], // also `class`
36
39
  * id: 'element-id',
37
40
  * attrs: [attr_name, attr_value] | [[...], [...]] | {name: value},
38
- * content: 'xxx' | 123 | domBuilder Array | function | Element,
41
+ * content: 'xxx' | 'xxx <strong>yyy</strong>' | 123 | domBuilder Array | function | Element, // see DomBuilderItem.content above
39
42
  * condition: true | false,
40
43
  * callback: el => ...,
41
44
  * children: [...]
@@ -78,20 +81,30 @@ export function domBuilder(structureArray = [], parent, options = {}) {
78
81
  ...options
79
82
  };
80
83
 
81
- /** @type {Map<HTMLElement, HTMLElement>} tracks, for `insertMode: 'after'`, the last inserted sibling per parent node */
84
+ if(parent && options.emptyParent) {
85
+ parent.innerHTML = '';
86
+ }
87
+
88
+ // For the default 'append' insert mode, root-level siblings are accumulated into a
89
+ // DocumentFragment and attached to `parent` with a single appendChild at the end,
90
+ // instead of one appendChild per sibling. 'before'/'after' modes need `target` to stay
91
+ // the real, live parent node throughout the loop for anchor resolution (see afterAnchors
92
+ // below), so batching is skipped for those.
93
+ const useFragment = !!parent && options.insertMode === 'append';
94
+
95
+ /** @type {HTMLElement | DocumentFragment | null} */
96
+ let target = useFragment ? document.createDocumentFragment() : (parent ?? null);
97
+
98
+ /** @type {Map<HTMLElement | DocumentFragment, HTMLElement>} tracks, for `insertMode: 'after'`, the last inserted sibling per parent node */
82
99
  const afterAnchors = new Map();
83
100
 
84
101
  /** @type {HTMLElement | null} */
85
102
  let mainElement = null;
86
103
  /** @type {HTMLElement} */
87
104
  let el;
88
- /** @type {HTMLElement | null} */
105
+ /** @type {HTMLElement | DocumentFragment | null} */
89
106
  let grand_parent = null;
90
107
 
91
- if(parent && options.emptyParent) {
92
- parent.innerHTML = '';
93
- }
94
-
95
108
  structureArray.forEach(inputItem => {
96
109
 
97
110
  /** @type {DomBuilderItem | null} */
@@ -112,7 +125,7 @@ export function domBuilder(structureArray = [], parent, options = {}) {
112
125
  // the last one receives the remaining object properties
113
126
  if (Array.isArray(safeItem.tag)) {
114
127
 
115
- grand_parent = parent ?? null;
128
+ grand_parent = target;
116
129
  const tags = /** @type {string[]} */ (safeItem.tag);
117
130
 
118
131
  tags.forEach((tagItem, idx) => {
@@ -130,10 +143,10 @@ export function domBuilder(structureArray = [], parent, options = {}) {
130
143
  );
131
144
 
132
145
  if (!isLast) { // the last element is handled by the standard path below
133
- if (parent) {
134
- parent.appendChild(el);
146
+ if (target) {
147
+ target.appendChild(el);
135
148
  }
136
- parent = el;
149
+ target = el;
137
150
  }
138
151
 
139
152
  });
@@ -148,23 +161,43 @@ export function domBuilder(structureArray = [], parent, options = {}) {
148
161
 
149
162
  if (safeItem.content != null) {
150
163
 
151
- /** @type {string | Element | null} */
152
- let content = null;
153
- if (typeof safeItem.content === 'function') {
154
- content = safeItem.content();
164
+ if (Array.isArray(safeItem.content)) {
165
+ // build directly into `el` (instead of passing no parent) so that every item of
166
+ // the array is actually inserted, not just the first one returned as `mainElement`
167
+ domBuilder(safeItem.content, el);
155
168
 
156
- } else if (Array.isArray(safeItem.content)) {
157
- content = domBuilder(safeItem.content);
169
+ } else {
158
170
 
159
- } else if (safeItem.content != null) {
160
- content = String(safeItem.content);
161
- }
171
+ /** @type {string | Element | null} */
172
+ let content = null;
173
+ if (typeof safeItem.content === 'function') {
174
+ content = safeItem.content();
175
+
176
+ } else if (safeItem.content != null) {
177
+ content = String(safeItem.content);
178
+ }
179
+
180
+ if (content instanceof Element) {
181
+ el.appendChild(content);
182
+
183
+ } else if (content != null) {
162
184
 
163
- if (content instanceof Element) {
164
- el.appendChild(content);
185
+ if (!content.includes('<')) {
186
+ // plain text: no HTML parsing needed, textContent is faster and safe by construction
187
+ el.textContent = content;
165
188
 
166
- } else if (content != null) {
167
- el.innerHTML = content;
189
+ } else if (typeof el.setHTML === 'function') {
190
+ // markup: sanitize via the native Sanitizer API, stripping <script>, event handler
191
+ // attributes, javascript: URLs, etc. while still allowing harmless formatting tags
192
+ // (<strong>, <em>, <a>, ...)
193
+ el.setHTML(content);
194
+
195
+ } else {
196
+ // Sanitizer API not supported by this browser: fall back to the historical,
197
+ // unsanitized behavior
198
+ el.innerHTML = content;
199
+ }
200
+ }
168
201
  }
169
202
  }
170
203
 
@@ -181,17 +214,18 @@ export function domBuilder(structureArray = [], parent, options = {}) {
181
214
  mainElement = el;
182
215
  }
183
216
 
184
- if (parent) {
217
+ if (target) {
185
218
  if (options.insertMode === 'before') {
186
- parent.parentNode?.insertBefore(el, parent);
219
+ const anchor = /** @type {HTMLElement} */ (target);
220
+ anchor.parentNode?.insertBefore(el, anchor);
187
221
 
188
222
  } else if (options.insertMode === 'after') {
189
- const anchor = afterAnchors.get(parent) ?? parent;
223
+ const anchor = afterAnchors.get(/** @type {HTMLElement} */ (target)) ?? /** @type {HTMLElement} */ (target);
190
224
  anchor.parentNode?.insertBefore(el, anchor.nextSibling);
191
- afterAnchors.set(parent, el);
225
+ afterAnchors.set(/** @type {HTMLElement} */ (target), el);
192
226
 
193
227
  } else {
194
- parent.appendChild(el);
228
+ target.appendChild(el);
195
229
  }
196
230
  }
197
231
 
@@ -201,11 +235,15 @@ export function domBuilder(structureArray = [], parent, options = {}) {
201
235
  }
202
236
 
203
237
  if (grand_parent) {
204
- parent = grand_parent;
238
+ target = grand_parent;
205
239
  }
206
240
  }
207
241
 
208
242
  });
209
243
 
244
+ if (useFragment && parent && target) {
245
+ parent.appendChild(target);
246
+ }
247
+
210
248
  return mainElement;
211
249
  }
@@ -1,6 +1,39 @@
1
1
 
2
2
  /** @typedef {import('./dom-builder.js').DomBuilderItem} DomBuilderItem */
3
3
 
4
+ // boolean attributes are omitted when their value is explicitly false;
5
+ // declared once at module level (Set for O(1) lookup) since the list never changes between calls
6
+ const boolAttrs = new Set([
7
+ 'allowfullscreen',
8
+ 'async',
9
+ 'autocomplete',
10
+ 'autofocus',
11
+ 'autoplay',
12
+ 'border',
13
+ 'checked',
14
+ 'compact',
15
+ 'contenteditable',
16
+ 'controls',
17
+ 'default',
18
+ 'loop',
19
+ 'defer',
20
+ 'disabled',
21
+ 'formnovalidate',
22
+ 'hidden',
23
+ 'inert',
24
+ 'ismap',
25
+ 'multiple',
26
+ 'muted',
27
+ 'novalidate',
28
+ 'open',
29
+ 'readonly',
30
+ 'required',
31
+ 'reversed',
32
+ 'selected',
33
+ 'spellcheck',
34
+ 'translate'
35
+ ]);
36
+
4
37
  /**
5
38
  * Applies basic domBuilder configuration to an element: classes, id, and attributes.
6
39
  * @param {HTMLElement} element - The element to configure.
@@ -23,42 +56,8 @@ export function domBuilderBasicSetup (element, domBuilderItem) {
23
56
  domBuilderItem.attrs = Object.entries(domBuilderItem.attrs);
24
57
  }
25
58
 
26
- // boolean attributes are omitted when their value is explicitly false
27
- const boolAttrs = [
28
- 'allowfullscreen',
29
- 'async',
30
- 'autocomplete',
31
- 'autofocus',
32
- 'autoplay',
33
- 'border',
34
- 'checked',
35
- 'compact',
36
- 'contenteditable',
37
- 'controls',
38
- 'default',
39
- 'loop',
40
- 'defer',
41
- 'disabled',
42
- 'formnovalidate',
43
- 'hidden',
44
- 'inert',
45
- 'ismap',
46
- 'multiple',
47
- 'muted',
48
- 'novalidate',
49
- 'open',
50
- 'readonly',
51
- 'required',
52
- 'reversed',
53
- 'selected',
54
- 'spellcheck',
55
- 'translate'
56
- ];
57
-
58
-
59
-
60
59
  (/** @type {[string, unknown][]} */ (domBuilderItem.attrs ?? [])).forEach(attr => {
61
- if (attr[1] != null && !(boolAttrs.includes(attr[0]) && attr[1]=== false)) {
60
+ if (attr[1] != null && !(boolAttrs.has(attr[0]) && attr[1] === false)) {
62
61
  element.setAttribute(attr[0], String(attr[1]));
63
62
  }
64
63
  });
@@ -1,3 +1,11 @@
1
+ // compiled once at module load, since the pattern never changes between calls
2
+ const DOM_STRING_REGEX = new RegExp(
3
+ /^([a-zA-Z][a-zA-Z0-9-]*)?/.source + // tag (also accepts web component names)
4
+ /((?:[#.][a-zA-Z0-9_-]+)*)?/.source + // id and classes, in any order
5
+ /([([{].*?[)\]}])?/.source + // attrs
6
+ /(?: +(.*))?$/.source // content
7
+ );
8
+
1
9
  /**
2
10
  * Parses a string to extract tag name, ID, classes, and attributes.
3
11
  *
@@ -24,17 +32,9 @@
24
32
  * @param {string} domString - The domBuilder string to parse.
25
33
  * @returns {{tag: string, id: string|null, className: string, attrs: Object<string, string|true>, content: string|null}|null}
26
34
  */
27
-
28
35
  export function parseDomString(domString) {
29
36
 
30
- const regex = new RegExp(
31
- /^([a-zA-Z][a-zA-Z0-9-]*)?/.source + // tag (also accepts web component names)
32
- /((?:[#.][a-zA-Z0-9_-]+)*)?/.source + // id and classes, in any order
33
- /([([{].*?[)\]}])?/.source + // attrs
34
- /(?: +(.*))?$/.source // content
35
- );
36
-
37
- const matches = domString.match(regex);
37
+ const matches = domString.match(DOM_STRING_REGEX);
38
38
 
39
39
 
40
40
  if (!matches) {