@arsedizioni/ars-utils 22.0.15 → 22.0.16

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,376 @@ 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.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
+ static parseBlocks(lines, opts) {
76
+ const out = [];
77
+ let paragraph = [];
78
+ const flush = () => {
79
+ if (paragraph.length > 0) {
80
+ const sep = opts.breaks ? '<br>' : ' ';
81
+ out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
82
+ paragraph = [];
83
+ }
84
+ };
85
+ for (let i = 0; i < lines.length; i++) {
86
+ const line = lines[i];
87
+ const trimmed = line.trim();
88
+ // --- Fenced code block -------------------------------------------------
89
+ const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
90
+ if (fence) {
91
+ flush();
92
+ const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
93
+ const code = [];
94
+ i++;
95
+ while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
96
+ code.push(lines[i]);
97
+ i++;
98
+ }
99
+ out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
100
+ continue;
101
+ }
102
+ // --- Blank line --------------------------------------------------------
103
+ if (!trimmed) {
104
+ flush();
105
+ continue;
106
+ }
107
+ // --- ATX heading -------------------------------------------------------
108
+ const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
109
+ if (heading) {
110
+ flush();
111
+ const level = heading[1].length;
112
+ out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
113
+ continue;
114
+ }
115
+ // --- Horizontal rule ---------------------------------------------------
116
+ if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
117
+ flush();
118
+ out.push('<hr>');
119
+ continue;
120
+ }
121
+ // --- Blockquote (consecutive lines, one level stripped, recursive) ------
122
+ if (/^>\s?/.test(trimmed)) {
123
+ flush();
124
+ const quoted = [];
125
+ while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
126
+ quoted.push(lines[i].trim().replace(/^>\s?/, ''));
127
+ i++;
128
+ }
129
+ i--;
130
+ out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
131
+ continue;
132
+ }
133
+ // --- Table (header row + separator row) ---------------------------------
134
+ if (trimmed.includes('|') &&
135
+ i + 1 < lines.length &&
136
+ lines[i + 1].includes('-') &&
137
+ /^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
138
+ flush();
139
+ const aligns = this.tableAligns(lines[i + 1].trim());
140
+ const head = this.tableRow(trimmed, 'th', aligns, opts);
141
+ const body = [];
142
+ i += 2;
143
+ while (i < lines.length && lines[i].trim().includes('|')) {
144
+ body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
145
+ i++;
146
+ }
147
+ i--;
148
+ out.push(`<table>\n<thead>\n${head}\n</thead>` +
149
+ (body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
150
+ `\n</table>`);
151
+ continue;
152
+ }
153
+ // --- List ---------------------------------------------------------------
154
+ if (MarkdownUtils.listItemRe.test(line)) {
155
+ flush();
156
+ const [html, last] = this.parseList(lines, i, opts);
157
+ out.push(html);
158
+ i = last;
159
+ continue;
160
+ }
161
+ // --- Plain text: accumulate into current paragraph ----------------------
162
+ paragraph.push(trimmed);
163
+ }
164
+ flush();
165
+ return out.join('\n');
166
+ }
167
+ // #endregion
168
+ // #region Lists
169
+ /** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
170
+ static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
171
+ /**
172
+ * Gathers the contiguous list block starting at `start`, builds it (with
173
+ * nesting) and returns [html, indexOfLastConsumedLine].
174
+ */
175
+ static parseList(lines, start, opts) {
176
+ const block = [];
177
+ let i = start;
178
+ while (i < lines.length) {
179
+ const l = lines[i];
180
+ if (!l.trim())
181
+ break; // blank line ends the list
182
+ if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
183
+ block.push(l);
184
+ i++;
185
+ }
186
+ else
187
+ break;
188
+ }
189
+ return [this.buildList(block, opts), i - 1];
190
+ }
191
+ static buildList(block, opts) {
192
+ const first = block[0].match(this.listItemRe);
193
+ const baseIndent = first[1].length;
194
+ const ordered = /^\d/.test(first[2]);
195
+ const startNum = ordered ? parseInt(first[2], 10) : 1;
196
+ const items = [];
197
+ for (const l of block) {
198
+ const m = l.match(this.listItemRe);
199
+ if (m && m[1].length <= baseIndent) {
200
+ items.push({ text: m[3], childLines: [] });
201
+ }
202
+ else if (items.length > 0) {
203
+ // Continuation or nested content: strip one nesting level of indent
204
+ items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
205
+ }
206
+ }
207
+ const lis = items.map(item => {
208
+ let inner;
209
+ // GFM task list: - [ ] / - [x]
210
+ const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
211
+ if (task) {
212
+ const checked = task[1] !== ' ' ? ' checked' : '';
213
+ inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
214
+ }
215
+ else {
216
+ inner = this.inline(item.text, opts);
217
+ }
218
+ if (item.childLines.length > 0) {
219
+ inner += '\n' + this.parseBlocks(item.childLines, opts);
220
+ }
221
+ return `<li>${inner}</li>`;
222
+ });
223
+ const tag = ordered ? 'ol' : 'ul';
224
+ const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
225
+ return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
226
+ }
227
+ // #endregion
228
+ // #region Tables
229
+ static tableAligns(separator) {
230
+ return separator
231
+ .replace(/^\||\|$/g, '')
232
+ .split('|')
233
+ .map(cell => {
234
+ const t = cell.trim();
235
+ if (t.startsWith(':') && t.endsWith(':'))
236
+ return 'center';
237
+ if (t.endsWith(':'))
238
+ return 'right';
239
+ if (t.startsWith(':'))
240
+ return 'left';
241
+ return undefined;
242
+ });
243
+ }
244
+ static tableRow(row, tag, aligns, opts) {
245
+ const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
246
+ const cellsHtml = cells
247
+ .map((cell, j) => {
248
+ const align = aligns[j] ? ` align="${aligns[j]}"` : '';
249
+ return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
250
+ })
251
+ .join('');
252
+ return `<tr>${cellsHtml}</tr>`;
253
+ }
254
+ // #endregion
255
+ // #region Inline formatting
256
+ /**
257
+ * Applies inline markdown to a single text segment.
258
+ * Generated HTML (code, links, images, autolinks) is stashed behind \u0000
259
+ * placeholders so later regex passes can never corrupt it.
260
+ */
261
+ static inline(text, opts) {
262
+ const store = [];
263
+ const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
264
+ // 1. Inline code: stash first so later passes can't touch it.
265
+ // Content is ALWAYS escaped so <, >, & inside code survive in the DOM
266
+ // (the browser renders entities back to the original characters).
267
+ let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
268
+ // 2. Escape raw HTML as entities (opt-in only)
269
+ if (opts.escapeHtml)
270
+ s = this.escape(s);
271
+ // 3. Images: ![alt](url "title")
272
+ s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, alt, src, title) => {
273
+ const url = this.safeUrl(src);
274
+ if (!url)
275
+ return alt;
276
+ const t = title ? ` title="${title}"` : '';
277
+ return stash(`<img src="${url}" alt="${alt}"${t} loading="lazy">`);
278
+ });
279
+ // 4. Links: [text](url "title")
280
+ s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|&quot;)(.+?)(?:"|&quot;))?\s*\)/g, (_m, txt, href, title) => {
281
+ const url = this.safeUrl(href);
282
+ if (!url)
283
+ return txt;
284
+ const t = title ? ` title="${title}"` : '';
285
+ return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
286
+ });
287
+ // 5. Autolink remaining bare URLs (markdown links are already stashed)
288
+ s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
289
+ // 6. Bold / italic / strikethrough.
290
+ // Delimiters must hug the text; underscore emphasis only at word
291
+ // boundaries so identifiers like snake_case are left untouched.
292
+ s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
293
+ s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
294
+ s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
295
+ s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
296
+ s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
297
+ s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
298
+ s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
299
+ // 7. Restore stashed HTML
300
+ return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
301
+ }
302
+ // #endregion
303
+ // #region Security helpers
304
+ /** Escapes &, <, > and " for safe HTML interpolation. */
305
+ static escape(s) {
306
+ return s
307
+ .replace(/&/g, '&amp;')
308
+ .replace(/</g, '&lt;')
309
+ .replace(/>/g, '&gt;')
310
+ .replace(/"/g, '&quot;');
311
+ }
312
+ /**
313
+ * Returns a sanitized URL or undefined when the scheme is dangerous.
314
+ * Blocks javascript:, vbscript: and data: (also when obfuscated with
315
+ * whitespace/control characters, e.g. "java\tscript:").
316
+ */
317
+ static safeUrl(url) {
318
+ const compact = url.replace(/[\s\u0000-\u001f]/g, '');
319
+ if (/^(javascript|vbscript|data):/i.test(compact))
320
+ return undefined;
321
+ return url.replace(/"/g, '%22');
322
+ }
323
+ // #endregion
324
+ // #region Clipboard
325
+ /**
326
+ * Copies markdown content to the clipboard in two flavors:
327
+ * - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
328
+ * - text/plain : the original markdown source (paste into editors, IDEs...)
329
+ * @param markdown : the markdown source
330
+ * @param options : conversion options for the HTML flavor
331
+ * @returns : true on success
332
+ */
333
+ static async copyToClipboard(markdown, options) {
334
+ if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
335
+ return false;
336
+ try {
337
+ if (typeof ClipboardItem !== 'undefined') {
338
+ const html = this.toHtml(markdown, options);
339
+ await navigator.clipboard.write([
340
+ new ClipboardItem({
341
+ 'text/html': new Blob([html], { type: 'text/html' }),
342
+ 'text/plain': new Blob([markdown], { type: 'text/plain' }),
343
+ }),
344
+ ]);
345
+ }
346
+ else {
347
+ // Older engines: plain text only
348
+ await navigator.clipboard.writeText(markdown);
349
+ }
350
+ return true;
351
+ }
352
+ catch {
353
+ // Clipboard API requires a secure context and user activation
354
+ return false;
355
+ }
356
+ }
357
+ /**
358
+ * Copies plain text (e.g. the content of a single code block) to the clipboard.
359
+ * @param text : the text to copy
360
+ * @returns : true on success
361
+ */
362
+ static async copyText(text) {
363
+ if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
364
+ return false;
365
+ try {
366
+ await navigator.clipboard.writeText(text);
367
+ return true;
368
+ }
369
+ catch {
370
+ return false;
371
+ }
372
+ }
373
+ /**
374
+ * Extracts the visible plain text from a rendered element
375
+ * (what the user sees, entities already decoded by the browser).
376
+ * @param element : the element hosting the rendered markdown
377
+ * @returns : the plain text
378
+ */
379
+ static elementToText(element) {
380
+ return element.innerText ?? element.textContent ?? '';
381
+ }
382
+ }
383
+
15
384
  var DateFormat;
16
385
  (function (DateFormat) {
17
386
  DateFormat[DateFormat["Short"] = 1] = "Short";
@@ -28,6 +397,8 @@ var DateFormat;
28
397
  DateFormat[DateFormat["ShortISO8601"] = 12] = "ShortISO8601"; // yyyy-mm-dd
29
398
  })(DateFormat || (DateFormat = {}));
30
399
  class SystemUtils {
400
+ /** Shared collator for locale-aware, case-insensitive string comparison. */
401
+ static { this.collator = new Intl.Collator('it', { sensitivity: 'base', numeric: true }); }
31
402
  /**
32
403
  * Array find by key
33
404
  * @param array : the array to scan
@@ -108,21 +479,23 @@ class SystemUtils {
108
479
  * Comparator factory for sorting arrays of objects by a given property key.
109
480
  * @param key - Name of the property to sort by.
110
481
  * @param order - Sort direction: `'asc'` (default) or `'desc'`.
111
- * @returns A comparator function that returns `0`, `1`, or `-1`.
482
+ * @returns A comparator function that returns a negative, zero, or positive number.
112
483
  */
113
484
  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)) {
485
+ const dir = order === 'desc' ? -1 : 1;
486
+ return (a, b) => {
487
+ const varA = a[key];
488
+ const varB = b[key];
489
+ if (varA === varB)
116
490
  return 0;
491
+ if (varA === undefined || varA === null)
492
+ return -1 * dir;
493
+ if (varB === undefined || varB === null)
494
+ return 1 * dir;
495
+ if (typeof varA === 'string' && typeof varB === 'string') {
496
+ return SystemUtils.collator.compare(varA, varB) * dir;
117
497
  }
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;
498
+ return (varA > varB ? 1 : varA < varB ? -1 : 0) * dir;
126
499
  };
127
500
  }
128
501
  /**
@@ -153,17 +526,13 @@ class SystemUtils {
153
526
  return `${bytes} byte`;
154
527
  }
155
528
  /**
156
- * Compare two string
529
+ * Compare two strings (case-insensitive, locale-aware, accent-insensitive).
157
530
  * @param a : string a
158
531
  * @param b : string b
159
532
  * @returns : 0 if equals, 1 if bigger, -1 if lower
160
533
  */
161
534
  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;
535
+ return this.collator.compare(a ?? '', b ?? '');
167
536
  }
168
537
  /**
169
538
  * Capitalize a string
@@ -191,7 +560,7 @@ class SystemUtils {
191
560
  return b;
192
561
  }
193
562
  /**
194
- * Truncate a string
563
+ * Truncate a string at the last word boundary before `max`.
195
564
  * @param s : the string to truncate
196
565
  * @param max : the max number of chars
197
566
  * @returns : the truncated string
@@ -201,13 +570,8 @@ class SystemUtils {
201
570
  return undefined;
202
571
  if (s.length < max)
203
572
  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;
573
+ const i = s.lastIndexOf(' ', max - 1);
574
+ return i > 0 ? s.slice(0, i) : s;
211
575
  }
212
576
  /**
213
577
  * Join a list of strings
@@ -280,247 +644,16 @@ class SystemUtils {
280
644
  static replaceAsHtml(s) {
281
645
  if (!s)
282
646
  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;
647
+ return s.replace(/https?:\/\/[^\s<]+/gi, v => `<a href="${v}" target="_blank" rel="noopener">${v}</a>`);
292
648
  }
293
649
  /**
294
650
  * Convert markdown to html
295
651
  * @param markdown : the markdown data
296
- * @param escapeEntities : true to escape entities. Default is false
652
+ * @param escapeHtml : true to escape HTML. Default is false
297
653
  * @returns the html
298
654
  */
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');
655
+ static markdownToHtml(markdown, escapeHtml = false) {
656
+ return MarkdownUtils.toHtml(markdown, { escapeHtml: escapeHtml });
524
657
  }
525
658
  /**
526
659
  * Compare two names
@@ -560,51 +693,44 @@ class SystemUtils {
560
693
  * @returns :the ciphered text
561
694
  */
562
695
  static cipher(text, key, reverse = false) {
696
+ if (!text || !key)
697
+ return undefined;
563
698
  // Surrogate pair limit
564
699
  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?
700
+ const keyLen = key.length;
701
+ let result = '';
702
+ for (let i = 0; i < text.length; i++) {
703
+ let rotation = key.charCodeAt(i % keyLen);
572
704
  if (reverse)
573
705
  rotation = -rotation;
574
- // Return current character code + rotation
575
- return (v.charCodeAt(0) + rotation + bound) % bound;
576
- }));
706
+ result += String.fromCharCode((text.charCodeAt(i) + rotation + bound) % bound);
707
+ }
708
+ return result;
577
709
  }
578
710
  /**
579
- * Clone an object using JSON
580
- * @param obj : the oblect to clone
711
+ * Clone an object (deep copy).
712
+ * Uses native structuredClone: handles Date, Map, Set, typed arrays and circular references.
713
+ * @param obj : the object to clone
581
714
  * @returns : a new object
582
715
  */
583
716
  static clone(obj) {
584
717
  if (!obj)
585
718
  return {};
586
- return JSON.parse(JSON.stringify(obj));
719
+ return structuredClone(obj);
587
720
  }
588
721
  /**
589
- * Creates a deep clone of an object by recursively copying all own properties.
722
+ * Creates a deep clone of an object.
590
723
  * @param obj - The source object to clone.
591
- * @param dest - Optional pre-allocated destination object; a new instance is created when omitted.
724
+ * @param dest - Optional pre-allocated destination object to merge the clone into.
592
725
  * @returns A deep copy of `obj`.
593
726
  */
594
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
595
727
  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
- }
728
+ const cloned = structuredClone(obj);
729
+ if (dest) {
730
+ Object.assign(dest, cloned);
731
+ return dest;
606
732
  }
607
- return cloneObj;
733
+ return cloned;
608
734
  }
609
735
  /**
610
736
  * Returns `true` when `value` is a syntactically valid UUID string.
@@ -633,6 +759,10 @@ class SystemUtils {
633
759
  * @returns : the string UUID
634
760
  */
635
761
  static generateUUID() {
762
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
763
+ return crypto.randomUUID();
764
+ }
765
+ // Fallback (non-secure contexts)
636
766
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
637
767
  const r = (Math.random() * 16) | 0;
638
768
  const v = c === 'x' ? r : (r & 0x3) | 0x8;
@@ -650,27 +780,46 @@ class SystemUtils {
650
780
  return s;
651
781
  return `${s.slice(0, 8)}-${s.slice(8, 12)}-${s.slice(12, 16)}-${s.slice(16, 20)}-${s.slice(20)}`;
652
782
  }
783
+ /** Precompiled validation patterns. */
784
+ 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; }
785
+ static { this.urlRegex = /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})([/\w .-]*)\/?$/i; }
653
786
  /**
654
- * Parse a text and return true if is a valid email or null
787
+ * Parse a text and return true if it is a valid email (or empty)
655
788
  * @param value : email
656
789
  * @returns : true if the email is valid or empty
657
790
  */
658
791
  static parseEmail(value) {
659
792
  if (!value)
660
793
  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());
794
+ return this.emailRegex.test(value.trim().toLowerCase());
795
+ }
796
+ /**
797
+ * Parse a text containing one or more email addresses separated by `;` or `,`
798
+ * and return true if all of them are valid (or the text is empty).
799
+ * Empty items caused by double or trailing separators are ignored.
800
+ * @param value : the email list to parse (e.g. "a@b.it; c@d.it")
801
+ * @returns : true if all emails are valid or the value is empty
802
+ */
803
+ static parseEmails(value) {
804
+ if (!value || value.trim().length === 0)
805
+ return true;
806
+ const items = value
807
+ .split(/[;,]/)
808
+ .map(e => e.trim().toLowerCase())
809
+ .filter(e => e.length > 0);
810
+ if (items.length === 0)
811
+ return false; // only separators, no addresses
812
+ return items.every(e => this.emailRegex.test(e));
663
813
  }
664
814
  /**
665
- * Parse a text and return true if is a valid url or null
815
+ * Parse a text and return true if it is a valid url (or empty)
666
816
  * @param value : the url to parse
667
817
  * @returns : true if the url is valid or empty
668
818
  */
669
819
  static parseUrl(value) {
670
820
  if (!value)
671
821
  return true;
672
- const regex = new RegExp("(https?://)?([\\da-z.-]+)\\.([a-z.]{2,6})[/\\w .-]*/?");
673
- return regex.test(value.trim().toLowerCase());
822
+ return this.urlRegex.test(value.trim().toLowerCase());
674
823
  }
675
824
  /**
676
825
  * Get date parts from a string value
@@ -874,16 +1023,8 @@ class SystemUtils {
874
1023
  static getQueryStringValueByName(name) {
875
1024
  if (!this.isBrowser())
876
1025
  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;
1026
+ const v = new URLSearchParams(window.location.search).get(name);
1027
+ return v && v !== 'null' ? v : undefined;
887
1028
  }
888
1029
  /**
889
1030
  * Generate a password
@@ -1061,6 +1202,8 @@ class SystemUtils {
1061
1202
  else
1062
1203
  return [];
1063
1204
  }
1205
+ /** Cache for resolved color luminance results. */
1206
+ static { this.colorLightCache = new Map(); }
1064
1207
  /**
1065
1208
  * Check if a color is light or dark
1066
1209
  * @param color : the color
@@ -1068,19 +1211,27 @@ class SystemUtils {
1068
1211
  * @returns true if the color is light
1069
1212
  */
1070
1213
  static isColorLight(color, minimumLuminance = 186) {
1214
+ if (!this.isBrowser())
1215
+ return true; // SSR fallback
1216
+ const cacheKey = `${color}|${minimumLuminance}`;
1217
+ const cached = this.colorLightCache.get(cacheKey);
1218
+ if (cached !== undefined)
1219
+ return cached;
1071
1220
  const tempDiv = document.createElement('div');
1072
1221
  tempDiv.style.color = color;
1073
1222
  document.body.appendChild(tempDiv);
1074
1223
  const rgb = getComputedStyle(tempDiv).color;
1075
1224
  document.body.removeChild(tempDiv);
1076
1225
  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;
1226
+ let light = true; // fallback
1227
+ if (result && result.length >= 3) {
1228
+ const r = parseInt(result[0], 10);
1229
+ const g = parseInt(result[1], 10);
1230
+ const b = parseInt(result[2], 10);
1231
+ light = (0.299 * r + 0.587 * g + 0.114 * b) > minimumLuminance;
1232
+ }
1233
+ this.colorLightCache.set(cacheKey, light);
1234
+ return light;
1084
1235
  }
1085
1236
  }
1086
1237