@massimo-cassandro/minimo 1.10.1 → 1.11.1

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.1",
4
4
  "description": "Light css framework & utilities",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -36,6 +36,7 @@
36
36
  "upd@m": "npx upd@m",
37
37
  "build-tokens": "node ./design-tokens/utilities/build-tokens.mjs --config ./design-tokens/tokens-config.mjs",
38
38
  "check unresolved props": "node ./design-tokens/utilities/check-unresolved-custom-props.mjs --config ./design-tokens/tokens-config.mjs",
39
+ "test": "node --test \"test/**/*.test.mjs\"",
39
40
  "checkJS": "npx tsc --noEmit -p jsconfig.json",
40
41
  "checkJS ./src": "npx tsc --noEmit --allowJs --checkJs --target ES2023 --module ESNext --allowSyntheticDefaultImports --moduleResolution bundler declarations.d.ts src/**/*.js"
41
42
  },
@@ -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": {
@@ -1,5 +1,5 @@
1
1
  // import './form.css'; // included in the main CSS entry point
2
- import { enableSubmitBtns } from '../../index.js';
2
+ import { enableSubmitBtns } from '../utilities/enable-submit-btns.js';
3
3
 
4
4
  /**
5
5
  * Wires up submit validation and button-disabling behaviour on all forms.
@@ -1,5 +1,41 @@
1
1
  // @ts-check
2
2
 
3
+ // Italian particles/articles (plus a few English and topographic terms) whose casing is
4
+ // enforced and after which spaces are turned into non-breaking spaces. Hoisted to module
5
+ // scope with the regexes/lookup built from it below, since none of this depends on the
6
+ // function's arguments and was previously rebuilt on every single betterText() call.
7
+ const PARTICELLE = [
8
+ 'e', 'ed',
9
+ 'di', 'a', 'da', 'in', 'per', 'con', 'su', 'per', 'tra', 'fra',
10
+ 'il', 'lo', 'la', 'i', 'gli', 'le',
11
+ 'un', 'uno', 'una',
12
+ 'del', 'dello', 'della', 'dei', 'degli', 'delle',
13
+ 'al', 'allo', 'alla', 'ai', 'agli', 'alle',
14
+ 'dal', 'dallo', 'dalla', 'dai', 'dagli', 'dalle',
15
+ 'nel', 'nello', 'nella', 'nei', 'negli', 'nelle',
16
+ 'col', 'collo', 'colla', 'con', 'coi', 'cogli', 'colle',
17
+ 'sul', 'sullo', 'sulla', 'sui', 'sugli', 'sulle',
18
+
19
+ // topographic terms
20
+ 'C.so', 'Corso', 'Via', 'P.za', 'Piazza', 'L.go', 'Largo', 'V.le', 'Viale',
21
+ 'De', 'San', 'c/o', 'Loc.',
22
+
23
+ // English
24
+ 'the', 'a', 'an', 'at', 'out', 'of',
25
+
26
+ // words used in geonames
27
+ 'isola', 'isole'
28
+ ];
29
+
30
+ // matches a particle followed by one-or-more spaces, to be turned into non-breaking spaces
31
+ const NBSP_AFTER_PARTICLE_REGEX = new RegExp('\\b(' + PARTICELLE.join('|') + ')\\b +', 'gmi');
32
+
33
+ // matches any particle regardless of case, to enforce its exact casing in a single pass
34
+ const PARTICLE_CASING_REGEX = new RegExp('\\b(' + PARTICELLE.join('|') + ')\\b', 'gmi');
35
+
36
+ // lowercased particle → its exact casing, for the replacer used with PARTICLE_CASING_REGEX
37
+ const PARTICLE_CASING_MAP = new Map(PARTICELLE.map(term => [term.toLowerCase(), term]));
38
+
3
39
  /**
4
40
  * Improves a text string by fixing punctuation spacing, multiple spaces,
5
41
  * typographic quotes, non-breaking spaces after Italian particles/articles,
@@ -11,29 +47,6 @@
11
47
  */
12
48
  export function betterText(str, custom_words = []) {
13
49
 
14
- const particelle = [
15
- 'e', 'ed',
16
- 'di', 'a', 'da', 'in', 'per', 'con', 'su', 'per', 'tra', 'fra',
17
- 'il', 'lo', 'la', 'i', 'gli', 'le',
18
- 'un', 'uno', 'una',
19
- 'del', 'dello', 'della', 'dei', 'degli', 'delle',
20
- 'al', 'allo', 'alla', 'ai', 'agli', 'alle',
21
- 'dal', 'dallo', 'dalla', 'dai', 'dagli', 'dalle',
22
- 'nel', 'nello', 'nella', 'nei', 'negli', 'nelle',
23
- 'col', 'collo', 'colla', 'con', 'coi', 'cogli', 'colle',
24
- 'sul', 'sullo', 'sulla', 'sui', 'sugli', 'sulle',
25
-
26
- // topographic terms
27
- 'C.so', 'Corso', 'Via', 'P.za', 'Piazza', 'L.go', 'Largo', 'V.le', 'Viale',
28
- 'De', 'San', 'c/o', 'Loc.',
29
-
30
- // English
31
- 'the', 'a', 'an', 'at', 'out', 'of',
32
-
33
- // words used in geonames
34
- 'isola', 'isole'
35
- ];
36
-
37
50
  if(str) {
38
51
  str = str.trim();
39
52
 
@@ -57,16 +70,12 @@ export function betterText(str, custom_words = []) {
57
70
  str = str.replace(/((^| )(l|un|d|all|dell|nell|sull)('|')) /gi, '$1' );
58
71
 
59
72
  // replace regular spaces with non-breaking spaces after Italian particles/articles
60
- var re = new RegExp('\\b(' + particelle.join('|') + ')\\b +', 'gmi');
61
- str = str.replace(re, function (match) {
73
+ str = str.replace(NBSP_AFTER_PARTICLE_REGEX, function (match) {
62
74
  return match.replace(/ +/g, '\u00A0');
63
75
  });
64
76
 
65
- // enforce exact casing of each particle
66
- particelle.forEach(term => {
67
- const re = new RegExp('\\b(' + term + ')\\b', 'gmi');
68
- str = str.replace(re, term);
69
- });
77
+ // enforce exact casing of every particle in a single pass
78
+ str = str.replace(PARTICLE_CASING_REGEX, match => PARTICLE_CASING_MAP.get(match.toLowerCase()) ?? match);
70
79
 
71
80
  if(custom_words.length) {
72
81
  custom_words.forEach(item => {
@@ -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) {