@arsedizioni/ars-utils 22.0.15 → 22.0.17

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.
@@ -2,7 +2,6 @@ import * as i0 from '@angular/core';
2
2
  import { Pipe, inject, makeEnvironmentProviders, Injectable, ElementRef, afterNextRender, Directive, input, DestroyRef, HostListener, output, forwardRef, EventEmitter, signal, computed, Service, RendererFactory2 } from '@angular/core';
3
3
  import { parseISO, parse, format, getYear, getMonth, getDate, getDay, getDaysInMonth, addYears, addMonths, addDays, formatISO, isDate, isValid, endOfDay } from 'date-fns';
4
4
  import { it } from 'date-fns/locale';
5
- import { HttpParams } from '@angular/common/http';
6
5
  import { TZDate } from '@date-fns/tz';
7
6
  import { DomSanitizer } from '@angular/platform-browser';
8
7
  import { DateAdapter, MAT_DATE_LOCALE, MAT_DATE_FORMATS } from '@angular/material/core';
@@ -12,6 +11,389 @@ import { debounceTime } from 'rxjs/operators';
12
11
  import { NG_VALIDATORS } from '@angular/forms';
13
12
  import { SelectionModel } from '@angular/cdk/collections';
14
13
 
14
+ /**
15
+ * Zero-dependency Markdown to HTML converter.
16
+ *
17
+ * Design goals:
18
+ * - Single line-based pass over blocks (no repeated full-string regex passes).
19
+ * - Inline formatting never touches code spans / code blocks (stash & restore).
20
+ * - No HTML entity escaping by default in normal text: the source passes as-is.
21
+ * Exception: the content of code spans / code blocks is ALWAYS escaped, so
22
+ * things like List<string> render correctly (the browser displays the
23
+ * original characters; entities never reach the user or the clipboard).
24
+ * Opt-in full escaping via { escapeHtml: true } for untrusted input.
25
+ * - URL sanitization on links/images (javascript:, vbscript:, data: are dropped).
26
+ * - Bounded result cache for repeated renders (Angular change detection friendly).
27
+ *
28
+ * Supported syntax: headings, paragraphs, hard/soft breaks, hr, blockquotes
29
+ * (nested), fenced code blocks, inline code, bold/italic/strikethrough, links
30
+ * (with title), images, autolinked bare URLs, ordered/unordered lists (nested,
31
+ * task lists, ordered start offset), GFM tables with alignment.
32
+ *
33
+ * Known simplifications (documented, by design):
34
+ * - A blank line terminates a list.
35
+ * - Setext headings (=== / ---) are not supported, use # syntax.
36
+ * - Reference-style links [text][ref] are not supported.
37
+ */
38
+ class MarkdownUtils {
39
+ // #region Cache
40
+ static { this.cache = new Map(); }
41
+ static { this.CACHE_MAX = 200; }
42
+ /** Clears the internal result cache. */
43
+ static clearCache() {
44
+ this.cache.clear();
45
+ }
46
+ // #endregion
47
+ // #region Public API
48
+ /**
49
+ * Convert markdown to HTML.
50
+ * @param markdown : the markdown source
51
+ * @param options : conversion options
52
+ * @returns : the HTML string ('' when input is falsy)
53
+ */
54
+ static toHtml(markdown, options) {
55
+ if (!markdown)
56
+ return '';
57
+ const opts = { escapeHtml: false, breaks: true, ...options };
58
+ const key = `${opts.escapeHtml ? 1 : 0}${opts.breaks ? 1 : 0}|${markdown}`;
59
+ const hit = this.cache.get(key);
60
+ if (hit !== undefined)
61
+ return hit;
62
+ // Normalize line endings and fix glued sentence punctuation ("dettaglio!Ecco")
63
+ const source = markdown
64
+ .replace(/\r\n?/g, '\n')
65
+ .replace(/([A-Za-zÀ-ÿ][!?])([A-Za-zÀ-ÿ])/g, '$1 $2');
66
+ const html = this.normalizeBreaks(this.parseBlocks(source.split('\n'), opts).trim());
67
+ if (this.cache.size >= this.CACHE_MAX) {
68
+ this.cache.delete(this.cache.keys().next().value); // FIFO eviction
69
+ }
70
+ this.cache.set(key, html);
71
+ return html;
72
+ }
73
+ // #endregion
74
+ // #region Block parser (single pass)
75
+ /**
76
+ * Normalizes <br> runs in the final HTML:
77
+ * - 3+ consecutive <br> (including <br/> / <br /> from the source) collapse to 2;
78
+ * - leading/trailing <br> inside a paragraph are removed.
79
+ * Code content is immune: <br> inside <code> is escaped as &lt;br&gt;.
80
+ */
81
+ static normalizeBreaks(html) {
82
+ return html
83
+ .replace(/(?:<br\s*\/?>\s*){3,}/gi, '<br><br>')
84
+ .replace(/<p>(?:<br\s*\/?>\s*)+/gi, '<p>')
85
+ .replace(/(?:\s*<br\s*\/?>)+<\/p>/gi, '</p>')
86
+ .replace(/<p>\s*<\/p>/gi, '');
87
+ }
88
+ static parseBlocks(lines, opts) {
89
+ const out = [];
90
+ let paragraph = [];
91
+ const flush = () => {
92
+ if (paragraph.length > 0) {
93
+ const sep = opts.breaks ? '<br>' : ' ';
94
+ out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
95
+ paragraph = [];
96
+ }
97
+ };
98
+ for (let i = 0; i < lines.length; i++) {
99
+ const line = lines[i];
100
+ const trimmed = line.trim();
101
+ // --- Fenced code block -------------------------------------------------
102
+ const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
103
+ if (fence) {
104
+ flush();
105
+ const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
106
+ const code = [];
107
+ i++;
108
+ while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
109
+ code.push(lines[i]);
110
+ i++;
111
+ }
112
+ out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
113
+ continue;
114
+ }
115
+ // --- Blank line --------------------------------------------------------
116
+ if (!trimmed) {
117
+ flush();
118
+ continue;
119
+ }
120
+ // --- ATX heading -------------------------------------------------------
121
+ const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
122
+ if (heading) {
123
+ flush();
124
+ const level = heading[1].length;
125
+ out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
126
+ continue;
127
+ }
128
+ // --- Horizontal rule ---------------------------------------------------
129
+ if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
130
+ flush();
131
+ out.push('<hr>');
132
+ continue;
133
+ }
134
+ // --- Blockquote (consecutive lines, one level stripped, recursive) ------
135
+ if (/^>\s?/.test(trimmed)) {
136
+ flush();
137
+ const quoted = [];
138
+ while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
139
+ quoted.push(lines[i].trim().replace(/^>\s?/, ''));
140
+ i++;
141
+ }
142
+ i--;
143
+ out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
144
+ continue;
145
+ }
146
+ // --- Table (header row + separator row) ---------------------------------
147
+ if (trimmed.includes('|') &&
148
+ i + 1 < lines.length &&
149
+ lines[i + 1].includes('-') &&
150
+ /^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
151
+ flush();
152
+ const aligns = this.tableAligns(lines[i + 1].trim());
153
+ const head = this.tableRow(trimmed, 'th', aligns, opts);
154
+ const body = [];
155
+ i += 2;
156
+ while (i < lines.length && lines[i].trim().includes('|')) {
157
+ body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
158
+ i++;
159
+ }
160
+ i--;
161
+ out.push(`<table>\n<thead>\n${head}\n</thead>` +
162
+ (body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
163
+ `\n</table>`);
164
+ continue;
165
+ }
166
+ // --- List ---------------------------------------------------------------
167
+ if (MarkdownUtils.listItemRe.test(line)) {
168
+ flush();
169
+ const [html, last] = this.parseList(lines, i, opts);
170
+ out.push(html);
171
+ i = last;
172
+ continue;
173
+ }
174
+ // --- Plain text: accumulate into current paragraph ----------------------
175
+ paragraph.push(trimmed);
176
+ }
177
+ flush();
178
+ return out.join('\n');
179
+ }
180
+ // #endregion
181
+ // #region Lists
182
+ /** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
183
+ static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
184
+ /**
185
+ * Gathers the contiguous list block starting at `start`, builds it (with
186
+ * nesting) and returns [html, indexOfLastConsumedLine].
187
+ */
188
+ static parseList(lines, start, opts) {
189
+ const block = [];
190
+ let i = start;
191
+ while (i < lines.length) {
192
+ const l = lines[i];
193
+ if (!l.trim())
194
+ break; // blank line ends the list
195
+ if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
196
+ block.push(l);
197
+ i++;
198
+ }
199
+ else
200
+ break;
201
+ }
202
+ return [this.buildList(block, opts), i - 1];
203
+ }
204
+ static buildList(block, opts) {
205
+ const first = block[0].match(this.listItemRe);
206
+ const baseIndent = first[1].length;
207
+ const ordered = /^\d/.test(first[2]);
208
+ const startNum = ordered ? parseInt(first[2], 10) : 1;
209
+ const items = [];
210
+ for (const l of block) {
211
+ const m = l.match(this.listItemRe);
212
+ if (m && m[1].length <= baseIndent) {
213
+ items.push({ text: m[3], childLines: [] });
214
+ }
215
+ else if (items.length > 0) {
216
+ // Continuation or nested content: strip one nesting level of indent
217
+ items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
218
+ }
219
+ }
220
+ const lis = items.map(item => {
221
+ let inner;
222
+ // GFM task list: - [ ] / - [x]
223
+ const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
224
+ if (task) {
225
+ const checked = task[1] !== ' ' ? ' checked' : '';
226
+ inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
227
+ }
228
+ else {
229
+ inner = this.inline(item.text, opts);
230
+ }
231
+ if (item.childLines.length > 0) {
232
+ inner += '\n' + this.parseBlocks(item.childLines, opts);
233
+ }
234
+ return `<li>${inner}</li>`;
235
+ });
236
+ const tag = ordered ? 'ol' : 'ul';
237
+ const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
238
+ return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
239
+ }
240
+ // #endregion
241
+ // #region Tables
242
+ static tableAligns(separator) {
243
+ return separator
244
+ .replace(/^\||\|$/g, '')
245
+ .split('|')
246
+ .map(cell => {
247
+ const t = cell.trim();
248
+ if (t.startsWith(':') && t.endsWith(':'))
249
+ return 'center';
250
+ if (t.endsWith(':'))
251
+ return 'right';
252
+ if (t.startsWith(':'))
253
+ return 'left';
254
+ return undefined;
255
+ });
256
+ }
257
+ static tableRow(row, tag, aligns, opts) {
258
+ const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
259
+ const cellsHtml = cells
260
+ .map((cell, j) => {
261
+ const align = aligns[j] ? ` align="${aligns[j]}"` : '';
262
+ return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
263
+ })
264
+ .join('');
265
+ return `<tr>${cellsHtml}</tr>`;
266
+ }
267
+ // #endregion
268
+ // #region Inline formatting
269
+ /**
270
+ * Applies inline markdown to a single text segment.
271
+ * Generated HTML (code, links, images, autolinks) is stashed behind \u0000
272
+ * placeholders so later regex passes can never corrupt it.
273
+ */
274
+ static inline(text, opts) {
275
+ const store = [];
276
+ const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
277
+ // 1. Inline code: stash first so later passes can't touch it.
278
+ // Content is ALWAYS escaped so <, >, & inside code survive in the DOM
279
+ // (the browser renders entities back to the original characters).
280
+ let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
281
+ // 2. Escape raw HTML as entities (opt-in only)
282
+ if (opts.escapeHtml)
283
+ s = this.escape(s);
284
+ // 3. Images: ![alt](url "title")
285
+ s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, alt, src, title) => {
286
+ const url = this.safeUrl(src);
287
+ if (!url)
288
+ return alt;
289
+ const t = title ? ` title="${title}"` : '';
290
+ return stash(`<img src="${url}" alt="${alt}"${t} loading="lazy">`);
291
+ });
292
+ // 4. Links: [text](url "title")
293
+ s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, txt, href, title) => {
294
+ const url = this.safeUrl(href);
295
+ if (!url)
296
+ return txt;
297
+ const t = title ? ` title="${title}"` : '';
298
+ return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
299
+ });
300
+ // 5. Autolink remaining bare URLs (markdown links are already stashed)
301
+ s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
302
+ // 6. Bold / italic / strikethrough.
303
+ // Delimiters must hug the text; underscore emphasis only at word
304
+ // boundaries so identifiers like snake_case are left untouched.
305
+ s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
306
+ s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
307
+ s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
308
+ s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
309
+ s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
310
+ s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
311
+ s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
312
+ // 7. Restore stashed HTML
313
+ return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
314
+ }
315
+ // #endregion
316
+ // #region Security helpers
317
+ /** Escapes &, <, > and " for safe HTML interpolation. */
318
+ static escape(s) {
319
+ return s
320
+ .replace(/&/g, '&amp;')
321
+ .replace(/</g, '&lt;')
322
+ .replace(/>/g, '&gt;')
323
+ .replace(/"/g, '&quot;');
324
+ }
325
+ /**
326
+ * Returns a sanitized URL or undefined when the scheme is dangerous.
327
+ * Blocks javascript:, vbscript: and data: (also when obfuscated with
328
+ * whitespace/control characters, e.g. "java\tscript:").
329
+ */
330
+ static safeUrl(url) {
331
+ const compact = url.replace(/[\s\u0000-\u001f]/g, '');
332
+ if (/^(javascript|vbscript|data):/i.test(compact))
333
+ return undefined;
334
+ return url.replace(/"/g, '%22');
335
+ }
336
+ // #endregion
337
+ // #region Clipboard
338
+ /**
339
+ * Copies markdown content to the clipboard in two flavors:
340
+ * - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
341
+ * - text/plain : the original markdown source (paste into editors, IDEs...)
342
+ * @param markdown : the markdown source
343
+ * @param options : conversion options for the HTML flavor
344
+ * @returns : true on success
345
+ */
346
+ static async copyToClipboard(markdown, options) {
347
+ if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
348
+ return false;
349
+ try {
350
+ if (typeof ClipboardItem !== 'undefined') {
351
+ const html = this.toHtml(markdown, options);
352
+ await navigator.clipboard.write([
353
+ new ClipboardItem({
354
+ 'text/html': new Blob([html], { type: 'text/html' }),
355
+ 'text/plain': new Blob([markdown], { type: 'text/plain' }),
356
+ }),
357
+ ]);
358
+ }
359
+ else {
360
+ // Older engines: plain text only
361
+ await navigator.clipboard.writeText(markdown);
362
+ }
363
+ return true;
364
+ }
365
+ catch {
366
+ // Clipboard API requires a secure context and user activation
367
+ return false;
368
+ }
369
+ }
370
+ /**
371
+ * Copies plain text (e.g. the content of a single code block) to the clipboard.
372
+ * @param text : the text to copy
373
+ * @returns : true on success
374
+ */
375
+ static async copyText(text) {
376
+ if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
377
+ return false;
378
+ try {
379
+ await navigator.clipboard.writeText(text);
380
+ return true;
381
+ }
382
+ catch {
383
+ return false;
384
+ }
385
+ }
386
+ /**
387
+ * Extracts the visible plain text from a rendered element
388
+ * (what the user sees, entities already decoded by the browser).
389
+ * @param element : the element hosting the rendered markdown
390
+ * @returns : the plain text
391
+ */
392
+ static elementToText(element) {
393
+ return element.innerText ?? element.textContent ?? '';
394
+ }
395
+ }
396
+
15
397
  var DateFormat;
16
398
  (function (DateFormat) {
17
399
  DateFormat[DateFormat["Short"] = 1] = "Short";
@@ -28,6 +410,8 @@ var DateFormat;
28
410
  DateFormat[DateFormat["ShortISO8601"] = 12] = "ShortISO8601"; // yyyy-mm-dd
29
411
  })(DateFormat || (DateFormat = {}));
30
412
  class SystemUtils {
413
+ /** Shared collator for locale-aware, case-insensitive string comparison. */
414
+ static { this.collator = new Intl.Collator('it', { sensitivity: 'base', numeric: true }); }
31
415
  /**
32
416
  * Array find by key
33
417
  * @param array : the array to scan
@@ -108,21 +492,23 @@ class SystemUtils {
108
492
  * Comparator factory for sorting arrays of objects by a given property key.
109
493
  * @param key - Name of the property to sort by.
110
494
  * @param order - Sort direction: `'asc'` (default) or `'desc'`.
111
- * @returns A comparator function that returns `0`, `1`, or `-1`.
495
+ * @returns A comparator function that returns a negative, zero, or positive number.
112
496
  */
113
497
  static arraySortCompare(key, order = 'asc') {
114
- return function innerSort(a, b) {
115
- if (!Object.prototype.hasOwnProperty.call(a, key) || !Object.prototype.hasOwnProperty.call(b, key)) {
498
+ const dir = order === 'desc' ? -1 : 1;
499
+ return (a, b) => {
500
+ const varA = a[key];
501
+ const varB = b[key];
502
+ if (varA === varB)
116
503
  return 0;
504
+ if (varA === undefined || varA === null)
505
+ return -1 * dir;
506
+ if (varB === undefined || varB === null)
507
+ return 1 * dir;
508
+ if (typeof varA === 'string' && typeof varB === 'string') {
509
+ return SystemUtils.collator.compare(varA, varB) * dir;
117
510
  }
118
- const varA = typeof a[key] === 'string' ? a[key].toUpperCase() : a[key];
119
- const varB = typeof b[key] === 'string' ? b[key].toUpperCase() : b[key];
120
- let comparison = 0;
121
- if (varA > varB)
122
- comparison = 1;
123
- else if (varA < varB)
124
- comparison = -1;
125
- return order === 'desc' ? comparison * -1 : comparison;
511
+ return (varA > varB ? 1 : varA < varB ? -1 : 0) * dir;
126
512
  };
127
513
  }
128
514
  /**
@@ -153,17 +539,13 @@ class SystemUtils {
153
539
  return `${bytes} byte`;
154
540
  }
155
541
  /**
156
- * Compare two string
542
+ * Compare two strings (case-insensitive, locale-aware, accent-insensitive).
157
543
  * @param a : string a
158
544
  * @param b : string b
159
545
  * @returns : 0 if equals, 1 if bigger, -1 if lower
160
546
  */
161
547
  static compareString(a, b) {
162
- if ((a ?? "").toLowerCase() > (b ?? "").toLowerCase())
163
- return 1;
164
- if ((a ?? "").toLowerCase() < (b ?? "").toLowerCase())
165
- return -1;
166
- return 0;
548
+ return this.collator.compare(a ?? '', b ?? '');
167
549
  }
168
550
  /**
169
551
  * Capitalize a string
@@ -191,7 +573,7 @@ class SystemUtils {
191
573
  return b;
192
574
  }
193
575
  /**
194
- * Truncate a string
576
+ * Truncate a string at the last word boundary before `max`.
195
577
  * @param s : the string to truncate
196
578
  * @param max : the max number of chars
197
579
  * @returns : the truncated string
@@ -201,13 +583,8 @@ class SystemUtils {
201
583
  return undefined;
202
584
  if (s.length < max)
203
585
  return s;
204
- let i = max - 1;
205
- while (i > 0) {
206
- if (s[i] === ' ')
207
- return s.slice(0, i);
208
- i--;
209
- }
210
- return s;
586
+ const i = s.lastIndexOf(' ', max - 1);
587
+ return i > 0 ? s.slice(0, i) : s;
211
588
  }
212
589
  /**
213
590
  * Join a list of strings
@@ -280,247 +657,16 @@ class SystemUtils {
280
657
  static replaceAsHtml(s) {
281
658
  if (!s)
282
659
  return '';
283
- const regex = /https?:\/\/.*[^\s]/ig;
284
- const m = regex.exec(s);
285
- if (!m || m.length === 0)
286
- return s;
287
- let result = s;
288
- for (const v of m) {
289
- result = result.replace(v, `<a href="${v}" target="_blank">${v}</a>`);
290
- }
291
- return result;
660
+ return s.replace(/https?:\/\/[^\s<]+/gi, v => `<a href="${v}" target="_blank" rel="noopener">${v}</a>`);
292
661
  }
293
662
  /**
294
663
  * Convert markdown to html
295
664
  * @param markdown : the markdown data
296
- * @param escapeEntities : true to escape entities. Default is false
665
+ * @param escapeHtml : true to escape HTML. Default is false
297
666
  * @returns the html
298
667
  */
299
- static markdownToHtml(markdown, escapeEntities = false) {
300
- if (!markdown)
301
- return '';
302
- let html = markdown;
303
- // 1. Escape HTML entities
304
- if (escapeEntities) {
305
- html = html.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
306
- }
307
- // 2. Protect code from inline formatting by replacing it with placeholders.
308
- // Placeholders are HTML-comment shaped so block detection treats them correctly.
309
- const codeStore = [];
310
- const stash = (content) => `<!--C${codeStore.push(content) - 1}-->`;
311
- // 2a. Fenced code blocks
312
- html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (_match, lang, code) => {
313
- const langClass = lang ? ` class="language-${lang}"` : '';
314
- return stash(`<pre><code${langClass}>${code.trim()}</code></pre>`);
315
- });
316
- // 2b. Inline code
317
- html = html.replace(/`([^`]+)`/g, (_match, code) => stash(`<code>${code}</code>`));
318
- // 2c. Ensure a space between two letters separated by sentence punctuation
319
- // (! or ?), e.g. "dettaglio!Ecco" becomes "dettaglio! Ecco".
320
- // Period, comma, colon and semicolon are ignored.
321
- html = html.replace(/([A-Za-zÀ-ÿ][!?])([A-Za-zÀ-ÿ])/g, '$1 $2');
322
- // 3. Headers
323
- html = html.replace(/^##### (.*$)/gim, '<h5>$1</h5>');
324
- html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
325
- html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
326
- html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
327
- html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
328
- // 4. Horizontal rules
329
- html = html.replace(/^---+$/gim, '<hr>');
330
- html = html.replace(/^\*\*\*+$/gim, '<hr>');
331
- // 5. Links
332
- html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
333
- // 6. Images
334
- html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1">');
335
- // 7. Bold and Italic.
336
- // Delimiters must hug the text (no inner spaces) so spacing between
337
- // separate styled spans is preserved. Underscore emphasis only applies at
338
- // word boundaries, so identifiers like snake_case are left untouched.
339
- html = html.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
340
- html = html.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
341
- html = html.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
342
- html = html.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
343
- html = html.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
344
- html = html.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
345
- // 8. Strikethrough
346
- html = html.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
347
- // 9. Blockquotes
348
- html = html.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>');
349
- // 10. Tables
350
- html = this.markdownConvertTables(html);
351
- // 11. Lists
352
- html = this.markdownConvertLists(html);
353
- // 12. Line breaks (two trailing spaces)
354
- html = html.replace(/ {2}$/gm, '<br>');
355
- // 13. Paragraphs (last block step, to avoid interfering with other elements)
356
- html = this.markdownConvertParagraphs(html);
357
- // 14. Collapse extra blank lines
358
- html = html.replace(/\n{3,}/g, '\n\n');
359
- // 15. Restore protected code placeholders
360
- html = html.replace(/<!--C(\d+)-->/g, (_match, index) => codeStore[Number(index)] ?? '');
361
- return html.trim();
362
- }
363
- /**
364
- * Convert markdown table to html
365
- * @param markdown: the markdown string
366
- * @returns : the html
367
- */
368
- static markdownConvertTables(markdown) {
369
- const lines = markdown.split('\n');
370
- const result = [];
371
- let inTable = false;
372
- let tableRows = [];
373
- let headerProcessed = false;
374
- for (let i = 0; i < lines.length; i++) {
375
- const line = lines[i].trim();
376
- if (line.includes('|') && line.length > 1) {
377
- const nextLine = i + 1 < lines.length ? lines[i + 1].trim() : '';
378
- const isHeaderSeparator = /^\|?[\s\-:|]+\|?$/.test(nextLine);
379
- if (!inTable) {
380
- inTable = true;
381
- headerProcessed = false;
382
- tableRows = [];
383
- }
384
- if (isHeaderSeparator && !headerProcessed) {
385
- tableRows.push(this.markdownConvertTableRow(line, true));
386
- headerProcessed = true;
387
- i++;
388
- }
389
- else if (!isHeaderSeparator) {
390
- tableRows.push(this.markdownConvertTableRow(line, false));
391
- }
392
- }
393
- else {
394
- if (inTable && tableRows.length > 0) {
395
- result.push('<table>');
396
- result.push(...tableRows);
397
- result.push('</table>');
398
- tableRows = [];
399
- inTable = false;
400
- }
401
- result.push(line);
402
- }
403
- }
404
- if (inTable && tableRows.length > 0) {
405
- result.push('<table>');
406
- result.push(...tableRows);
407
- result.push('</table>');
408
- }
409
- return result.join('\n');
410
- }
411
- /**
412
- * Convert a markdown table row to html
413
- * @param row : the markdown row
414
- * @param isHeader : true if is header
415
- * @returns the html
416
- */
417
- static markdownConvertTableRow(row, isHeader) {
418
- const cells = row.split('|').map(cell => cell.trim()).filter(cell => cell !== '');
419
- const tag = isHeader ? 'th' : 'td';
420
- const cellsHtml = cells.map(cell => ` <${tag}>${cell}</${tag}>`).join('\n');
421
- return ` <tr>\n${cellsHtml}\n </tr>`;
422
- }
423
- /**
424
- * Convert a markdown list to html
425
- * @param markdown : the markdown list
426
- * @returns the html
427
- */
428
- static markdownConvertLists(markdown) {
429
- markdown = markdown.replace(/^\* (.*$)/gim, '<li data-type="ul">$1</li>');
430
- markdown = markdown.replace(/^- (.*$)/gim, '<li data-type="ul">$1</li>');
431
- markdown = markdown.replace(/^\+ (.*$)/gim, '<li data-type="ul">$1</li>');
432
- markdown = markdown.replace(/^\d+\. (.*$)/gim, '<li data-type="ol">$1</li>');
433
- const lines = markdown.split('\n');
434
- const result = [];
435
- let currentList = { type: null, items: [] };
436
- for (const line of lines) {
437
- const ulMatch = line.match(/^<li data-type="ul">(.*)<\/li>$/);
438
- const olMatch = line.match(/^<li data-type="ol">(.*)<\/li>$/);
439
- if (ulMatch) {
440
- if (currentList.type !== 'ul') {
441
- if (currentList.type && currentList.items.length > 0) {
442
- result.push(this.markdownWrapList(currentList.type, currentList.items));
443
- }
444
- currentList = { type: 'ul', items: [ulMatch[1]] };
445
- }
446
- else {
447
- currentList.items.push(ulMatch[1]);
448
- }
449
- }
450
- else if (olMatch) {
451
- if (currentList.type !== 'ol') {
452
- if (currentList.type && currentList.items.length > 0) {
453
- result.push(this.markdownWrapList(currentList.type, currentList.items));
454
- }
455
- currentList = { type: 'ol', items: [olMatch[1]] };
456
- }
457
- else {
458
- currentList.items.push(olMatch[1]);
459
- }
460
- }
461
- else {
462
- if (currentList.type && currentList.items.length > 0) {
463
- result.push(this.markdownWrapList(currentList.type, currentList.items));
464
- currentList = { type: null, items: [] };
465
- }
466
- result.push(line);
467
- }
468
- }
469
- if (currentList.type && currentList.items.length > 0) {
470
- result.push(this.markdownWrapList(currentList.type, currentList.items));
471
- }
472
- return result.join('\n');
473
- }
474
- /**
475
- * Wrap a list
476
- * @param type: the list type
477
- * @param items : the item list
478
- * @returns the html
479
- */
480
- static markdownWrapList(type, items) {
481
- const listItems = items.map(item => ` <li>${item}</li>`).join('\n');
482
- return `<${type}>\n${listItems}\n</${type}>`;
483
- }
484
- /**
485
- * Convert markdown paragraphs to html
486
- * @param markdown : the markdown paragraphs
487
- * @returns the html
488
- */
489
- static markdownConvertParagraphs(markdown) {
490
- // Block-level tags that must not be wrapped in a paragraph.
491
- const blockTag = /^<\/?(h[1-6]|ul|ol|li|table|thead|tbody|tfoot|tr|td|th|pre|blockquote|hr|p|div)\b/i;
492
- // A line consisting solely of a protected code placeholder is block-level.
493
- const codePlaceholder = /^<!--C\d+-->$/;
494
- const lines = markdown.split('\n');
495
- const result = [];
496
- // Buffer of consecutive plain-text lines that belong to the same paragraph.
497
- let paragraph = [];
498
- // Flush the buffered lines as a single paragraph, joining soft line breaks
499
- // (single newlines) with a <br> so each source line keeps its own visual row.
500
- const flushParagraph = () => {
501
- if (paragraph.length > 0) {
502
- result.push(`<p>${paragraph.join('<br>')}</p>`);
503
- paragraph = [];
504
- }
505
- };
506
- for (const line of lines) {
507
- const trimmed = line.trim();
508
- if (!trimmed) {
509
- flushParagraph();
510
- result.push('');
511
- continue;
512
- }
513
- if (blockTag.test(trimmed) || codePlaceholder.test(trimmed)) {
514
- flushParagraph();
515
- result.push(line);
516
- continue;
517
- }
518
- // Plain text or inline-level content (strong, em, a, code, ...) becomes a paragraph.
519
- // Lines separated by a single newline are merged into the same paragraph.
520
- paragraph.push(trimmed);
521
- }
522
- flushParagraph();
523
- return result.join('\n');
668
+ static markdownToHtml(markdown, escapeHtml = false) {
669
+ return MarkdownUtils.toHtml(markdown, { escapeHtml: escapeHtml });
524
670
  }
525
671
  /**
526
672
  * Compare two names
@@ -560,51 +706,44 @@ class SystemUtils {
560
706
  * @returns :the ciphered text
561
707
  */
562
708
  static cipher(text, key, reverse = false) {
709
+ if (!text || !key)
710
+ return undefined;
563
711
  // Surrogate pair limit
564
712
  const bound = 0x10000;
565
- // Create string from character codes
566
- return String.fromCharCode.apply(null,
567
- // Turn string to character codes
568
- text.split('').map((v, i) => {
569
- // Get rotation from key
570
- let rotation = key.charCodeAt(i % key.length);
571
- // Are we decrypting?
713
+ const keyLen = key.length;
714
+ let result = '';
715
+ for (let i = 0; i < text.length; i++) {
716
+ let rotation = key.charCodeAt(i % keyLen);
572
717
  if (reverse)
573
718
  rotation = -rotation;
574
- // Return current character code + rotation
575
- return (v.charCodeAt(0) + rotation + bound) % bound;
576
- }));
719
+ result += String.fromCharCode((text.charCodeAt(i) + rotation + bound) % bound);
720
+ }
721
+ return result;
577
722
  }
578
723
  /**
579
- * Clone an object using JSON
580
- * @param obj : the oblect to clone
724
+ * Clone an object (deep copy).
725
+ * Uses native structuredClone: handles Date, Map, Set, typed arrays and circular references.
726
+ * @param obj : the object to clone
581
727
  * @returns : a new object
582
728
  */
583
729
  static clone(obj) {
584
730
  if (!obj)
585
731
  return {};
586
- return JSON.parse(JSON.stringify(obj));
732
+ return structuredClone(obj);
587
733
  }
588
734
  /**
589
- * Creates a deep clone of an object by recursively copying all own properties.
735
+ * Creates a deep clone of an object.
590
736
  * @param obj - The source object to clone.
591
- * @param dest - Optional pre-allocated destination object; a new instance is created when omitted.
737
+ * @param dest - Optional pre-allocated destination object to merge the clone into.
592
738
  * @returns A deep copy of `obj`.
593
739
  */
594
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
595
740
  static deepClone(obj, dest) {
596
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
597
- const cloneObj = dest ?? obj.constructor();
598
- for (const attribute of Object.keys(obj)) {
599
- const property = obj[attribute];
600
- if (typeof property === 'object' && property !== null) {
601
- cloneObj[attribute] = this.deepClone(property);
602
- }
603
- else {
604
- cloneObj[attribute] = property;
605
- }
741
+ const cloned = structuredClone(obj);
742
+ if (dest) {
743
+ Object.assign(dest, cloned);
744
+ return dest;
606
745
  }
607
- return cloneObj;
746
+ return cloned;
608
747
  }
609
748
  /**
610
749
  * Returns `true` when `value` is a syntactically valid UUID string.
@@ -633,6 +772,10 @@ class SystemUtils {
633
772
  * @returns : the string UUID
634
773
  */
635
774
  static generateUUID() {
775
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
776
+ return crypto.randomUUID();
777
+ }
778
+ // Fallback (non-secure contexts)
636
779
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
637
780
  const r = (Math.random() * 16) | 0;
638
781
  const v = c === 'x' ? r : (r & 0x3) | 0x8;
@@ -650,27 +793,46 @@ class SystemUtils {
650
793
  return s;
651
794
  return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
652
795
  }
796
+ /** Precompiled validation patterns. */
797
+ static { this.emailRegex = /^(?:[a-z0-9+!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i; }
798
+ static { this.urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)\/?$/i; }
653
799
  /**
654
- * Parse a text and return true if is a valid email or null
800
+ * Parse a text and return true if it is a valid email (or empty)
655
801
  * @param value : email
656
802
  * @returns : true if the email is valid or empty
657
803
  */
658
804
  static parseEmail(value) {
659
805
  if (!value)
660
806
  return true;
661
- const regex = /(?:[a-z0-9+!#$%&'*+\/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+\/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/gi;
662
- return regex.test(value.trim().toLowerCase());
807
+ return this.emailRegex.test(value.trim().toLowerCase());
808
+ }
809
+ /**
810
+ * Parse a text containing one or more email addresses separated by `;` or `,`
811
+ * and return true if all of them are valid (or the text is empty).
812
+ * Empty items caused by double or trailing separators are ignored.
813
+ * @param value : the email list to parse (e.g. "a@b.it; c@d.it")
814
+ * @returns : true if all emails are valid or the value is empty
815
+ */
816
+ static parseEmails(value) {
817
+ if (!value || value.trim().length === 0)
818
+ return true;
819
+ const items = value
820
+ .split(/[;,]/)
821
+ .map(e => e.trim().toLowerCase())
822
+ .filter(e => e.length > 0);
823
+ if (items.length === 0)
824
+ return false; // only separators, no addresses
825
+ return items.every(e => this.emailRegex.test(e));
663
826
  }
664
827
  /**
665
- * Parse a text and return true if is a valid url or null
828
+ * Parse a text and return true if it is a valid url (or empty)
666
829
  * @param value : the url to parse
667
830
  * @returns : true if the url is valid or empty
668
831
  */
669
832
  static parseUrl(value) {
670
833
  if (!value)
671
834
  return true;
672
- const regex = new RegExp("(https?://)?([\\da-z.-]+)\\.([a-z.]{2,6})[/\\w .-]*/?");
673
- return regex.test(value.trim().toLowerCase());
835
+ return this.urlRegex.test(value.trim().toLowerCase());
674
836
  }
675
837
  /**
676
838
  * Get date parts from a string value
@@ -874,16 +1036,8 @@ class SystemUtils {
874
1036
  static getQueryStringValueByName(name) {
875
1037
  if (!this.isBrowser())
876
1038
  return undefined;
877
- const url = window.location.href;
878
- if (url.includes('?')) {
879
- const httpParams = new HttpParams({ fromString: url.split('?')[1] });
880
- const raw = httpParams.get(name);
881
- if (!raw)
882
- return undefined;
883
- const v = decodeURIComponent(raw);
884
- return v && v !== 'null' ? v : undefined;
885
- }
886
- return undefined;
1039
+ const v = new URLSearchParams(window.location.search).get(name);
1040
+ return v && v !== 'null' ? v : undefined;
887
1041
  }
888
1042
  /**
889
1043
  * Generate a password
@@ -1061,6 +1215,8 @@ class SystemUtils {
1061
1215
  else
1062
1216
  return [];
1063
1217
  }
1218
+ /** Cache for resolved color luminance results. */
1219
+ static { this.colorLightCache = new Map(); }
1064
1220
  /**
1065
1221
  * Check if a color is light or dark
1066
1222
  * @param color : the color
@@ -1068,19 +1224,27 @@ class SystemUtils {
1068
1224
  * @returns true if the color is light
1069
1225
  */
1070
1226
  static isColorLight(color, minimumLuminance = 186) {
1227
+ if (!this.isBrowser())
1228
+ return true; // SSR fallback
1229
+ const cacheKey = `${color}|${minimumLuminance}`;
1230
+ const cached = this.colorLightCache.get(cacheKey);
1231
+ if (cached !== undefined)
1232
+ return cached;
1071
1233
  const tempDiv = document.createElement('div');
1072
1234
  tempDiv.style.color = color;
1073
1235
  document.body.appendChild(tempDiv);
1074
1236
  const rgb = getComputedStyle(tempDiv).color;
1075
1237
  document.body.removeChild(tempDiv);
1076
1238
  const result = rgb.match(/\d+/g);
1077
- if (!result || result.length < 3)
1078
- return true; // fallback
1079
- const r = parseInt(result[0], 10);
1080
- const g = parseInt(result[1], 10);
1081
- const b = parseInt(result[2], 10);
1082
- const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
1083
- return luminance > minimumLuminance;
1239
+ let light = true; // fallback
1240
+ if (result && result.length >= 3) {
1241
+ const r = parseInt(result[0], 10);
1242
+ const g = parseInt(result[1], 10);
1243
+ const b = parseInt(result[2], 10);
1244
+ light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
1245
+ }
1246
+ this.colorLightCache.set(cacheKey, light);
1247
+ return light;
1084
1248
  }
1085
1249
  }
1086
1250