@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.
- package/fesm2022/arsedizioni-ars-utils-core.mjs +475 -311
- package/fesm2022/arsedizioni-ars-utils-core.mjs.map +1 -1
- package/package.json +1 -1
- package/types/arsedizioni-ars-utils-clipper.ui.d.ts +1 -1
- package/types/arsedizioni-ars-utils-core.d.ts +27 -43
- package/types/arsedizioni-ars-utils-ui.application.d.ts +2 -2
- package/types/arsedizioni-ars-utils-ui.d.ts +1 -1
|
@@ -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 <br>.
|
|
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: 
|
|
285
|
+
s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|")(.+?)(?:"|"))?\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+(?:"|")(.+?)(?:"|"))?\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, '&')
|
|
321
|
+
.replace(/</g, '<')
|
|
322
|
+
.replace(/>/g, '>')
|
|
323
|
+
.replace(/"/g, '"');
|
|
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
|
|
495
|
+
* @returns A comparator function that returns a negative, zero, or positive number.
|
|
112
496
|
*/
|
|
113
497
|
static arraySortCompare(key, order = 'asc') {
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
|
665
|
+
* @param escapeHtml : true to escape HTML. Default is false
|
|
297
666
|
* @returns the html
|
|
298
667
|
*/
|
|
299
|
-
static markdownToHtml(markdown,
|
|
300
|
-
|
|
301
|
-
return '';
|
|
302
|
-
let html = markdown;
|
|
303
|
-
// 1. Escape HTML entities
|
|
304
|
-
if (escapeEntities) {
|
|
305
|
-
html = html.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
719
|
+
result += String.fromCharCode((text.charCodeAt(i) + rotation + bound) % bound);
|
|
720
|
+
}
|
|
721
|
+
return result;
|
|
577
722
|
}
|
|
578
723
|
/**
|
|
579
|
-
* Clone an object
|
|
580
|
-
*
|
|
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
|
|
732
|
+
return structuredClone(obj);
|
|
587
733
|
}
|
|
588
734
|
/**
|
|
589
|
-
* Creates a deep clone of an object
|
|
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
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
662
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
878
|
-
|
|
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
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
|