@arsedizioni/ars-utils 22.1.5 → 22.5.2
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/README.md +158 -10
- package/clipper.ui/clipper.scss +564 -0
- package/fesm2022/arsedizioni-ars-utils-clipper.common.mjs +1 -1
- package/fesm2022/arsedizioni-ars-utils-clipper.common.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-clipper.ui.mjs +11 -7
- package/fesm2022/arsedizioni-ars-utils-clipper.ui.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-core.date.mjs +609 -0
- package/fesm2022/arsedizioni-ars-utils-core.date.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-core.markdown.mjs +591 -0
- package/fesm2022/arsedizioni-ars-utils-core.markdown.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-core.mjs +270 -1449
- package/fesm2022/arsedizioni-ars-utils-core.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-evolution.common.mjs +1 -1
- package/fesm2022/arsedizioni-ars-utils-evolution.common.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-support.common.mjs +1 -1
- package/fesm2022/arsedizioni-ars-utils-support.common.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-ui.application.mjs +14 -4108
- package/fesm2022/arsedizioni-ars-utils-ui.application.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-ui.controls.mjs +962 -0
- package/fesm2022/arsedizioni-ars-utils-ui.controls.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.auth.mjs +506 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.auth.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.mjs +450 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.prompt.mjs +184 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.prompt.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.select.mjs +825 -0
- package/fesm2022/arsedizioni-ars-utils-ui.dialogs.select.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.files.mjs +708 -0
- package/fesm2022/arsedizioni-ars-utils-ui.files.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.filters.mjs +426 -0
- package/fesm2022/arsedizioni-ars-utils-ui.filters.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.help.mjs +531 -0
- package/fesm2022/arsedizioni-ars-utils-ui.help.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.mjs +54 -1227
- package/fesm2022/arsedizioni-ars-utils-ui.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-ui.navigation.mjs +1049 -0
- package/fesm2022/arsedizioni-ars-utils-ui.navigation.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.notifications.mjs +263 -0
- package/fesm2022/arsedizioni-ars-utils-ui.notifications.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.oauth.mjs +4 -3
- package/fesm2022/arsedizioni-ars-utils-ui.oauth.mjs.map +1 -1
- package/fesm2022/arsedizioni-ars-utils-ui.paginator.mjs +53 -0
- package/fesm2022/arsedizioni-ars-utils-ui.paginator.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.shell.mjs +517 -0
- package/fesm2022/arsedizioni-ars-utils-ui.shell.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils-ui.tinymce.mjs +246 -0
- package/fesm2022/arsedizioni-ars-utils-ui.tinymce.mjs.map +1 -0
- package/fesm2022/arsedizioni-ars-utils.mjs +13 -12
- package/fesm2022/arsedizioni-ars-utils.mjs.map +1 -1
- package/help/package.json +4 -0
- package/package.json +80 -14
- package/styles/ui.colors.scss +23 -0
- package/support.ui/package.json +4 -0
- package/tinymce/package.json +4 -0
- package/types/arsedizioni-ars-utils-clipper.common.d.ts +1 -2
- package/types/arsedizioni-ars-utils-clipper.ui.d.ts +4 -1
- package/types/arsedizioni-ars-utils-core.d.ts +684 -1004
- package/types/arsedizioni-ars-utils-core.date.d.ts +304 -0
- package/types/arsedizioni-ars-utils-core.markdown.d.ts +159 -0
- package/types/arsedizioni-ars-utils-ui.application.d.ts +10 -2022
- package/types/arsedizioni-ars-utils-ui.controls.d.ts +413 -0
- package/types/arsedizioni-ars-utils-ui.d.ts +183 -566
- package/types/arsedizioni-ars-utils-ui.dialogs.auth.d.ts +224 -0
- package/types/arsedizioni-ars-utils-ui.dialogs.d.ts +228 -0
- package/types/arsedizioni-ars-utils-ui.dialogs.prompt.d.ts +47 -0
- package/types/arsedizioni-ars-utils-ui.dialogs.select.d.ts +309 -0
- package/types/arsedizioni-ars-utils-ui.files.d.ts +271 -0
- package/types/arsedizioni-ars-utils-ui.filters.d.ts +163 -0
- package/types/arsedizioni-ars-utils-ui.help.d.ts +220 -0
- package/types/arsedizioni-ars-utils-ui.navigation.d.ts +650 -0
- package/types/arsedizioni-ars-utils-ui.notifications.d.ts +73 -0
- package/types/arsedizioni-ars-utils-ui.paginator.d.ts +23 -0
- package/types/arsedizioni-ars-utils-ui.shell.d.ts +345 -0
- package/types/arsedizioni-ars-utils-ui.tinymce.d.ts +63 -0
- package/types/arsedizioni-ars-utils.d.ts +4 -10
- package/ui.tinymce/README.md +12 -0
- package/ui.tinymce/langs/it.js +466 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, Pipe } from '@angular/core';
|
|
3
|
+
import { DomSanitizer } from '@angular/platform-browser';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Zero-dependency Markdown to HTML converter.
|
|
7
|
+
*
|
|
8
|
+
* Design goals:
|
|
9
|
+
* - Single line-based pass over blocks (no repeated full-string regex passes).
|
|
10
|
+
* - Inline formatting never touches code spans / code blocks (stash & restore).
|
|
11
|
+
* - No HTML entity escaping by default in normal text: the source passes as-is.
|
|
12
|
+
* Exception: the content of code spans / code blocks is ALWAYS escaped, so
|
|
13
|
+
* things like List<string> render correctly (the browser displays the
|
|
14
|
+
* original characters; entities never reach the user or the clipboard).
|
|
15
|
+
* Opt-in full escaping via { escapeHtml: true } for untrusted input.
|
|
16
|
+
* - URL sanitization on links/images (javascript:, vbscript:, data: are dropped).
|
|
17
|
+
* - Bounded result cache for repeated renders (Angular change detection friendly).
|
|
18
|
+
*
|
|
19
|
+
* Supported syntax: headings, paragraphs, hard/soft breaks, hr, blockquotes
|
|
20
|
+
* (nested), fenced code blocks, inline code, bold/italic/strikethrough, links
|
|
21
|
+
* (with title), images, autolinked bare URLs, ordered/unordered lists (nested,
|
|
22
|
+
* task lists, ordered start offset), GFM tables with alignment, raw HTML
|
|
23
|
+
* blocks (e.g. <table>...</table>) passed through verbatim when escapeHtml
|
|
24
|
+
* is false: their newlines stay plain newlines, never converted to <br>.
|
|
25
|
+
*
|
|
26
|
+
* Known simplifications (documented, by design):
|
|
27
|
+
* - A blank line terminates a list.
|
|
28
|
+
* - Setext headings (=== / ---) are not supported, use # syntax.
|
|
29
|
+
* - Reference-style links [text][ref] are not supported.
|
|
30
|
+
*/
|
|
31
|
+
class MarkdownUtils {
|
|
32
|
+
// #region Cache
|
|
33
|
+
static { this.cache = new Map(); }
|
|
34
|
+
static { this.CACHE_MAX = 200; }
|
|
35
|
+
/** Clears the internal result cache. */
|
|
36
|
+
static clearCache() {
|
|
37
|
+
this.cache.clear();
|
|
38
|
+
}
|
|
39
|
+
// #endregion
|
|
40
|
+
// #region Public API
|
|
41
|
+
/**
|
|
42
|
+
* Convert markdown to HTML.
|
|
43
|
+
* @param markdown : the markdown source
|
|
44
|
+
* @param options : conversion options
|
|
45
|
+
* @returns : the HTML string ('' when input is falsy)
|
|
46
|
+
*/
|
|
47
|
+
static toHtml(markdown, options) {
|
|
48
|
+
if (!markdown)
|
|
49
|
+
return '';
|
|
50
|
+
const opts = { escapeHtml: false, breaks: true, ...options };
|
|
51
|
+
const key = `${opts.escapeHtml ? 1 : 0}${opts.breaks ? 1 : 0}|${markdown}`;
|
|
52
|
+
const hit = this.cache.get(key);
|
|
53
|
+
if (hit !== undefined)
|
|
54
|
+
return hit;
|
|
55
|
+
// Normalize line endings and fix glued sentence punctuation ("dettaglio!Ecco").
|
|
56
|
+
// Only when the next char is uppercase (a new sentence), so URLs with query
|
|
57
|
+
// strings ("/page?id=1") and code identifiers are left untouched.
|
|
58
|
+
const source = markdown
|
|
59
|
+
.replace(/\r\n?/g, '\n')
|
|
60
|
+
.replace(/([a-zà-ÿ][!?])([A-ZÀ-Þ])/g, '$1 $2');
|
|
61
|
+
const html = this.parseBlocks(source.split('\n'), opts).trim();
|
|
62
|
+
if (this.cache.size >= this.CACHE_MAX) {
|
|
63
|
+
this.cache.delete(this.cache.keys().next().value); // FIFO eviction
|
|
64
|
+
}
|
|
65
|
+
this.cache.set(key, html);
|
|
66
|
+
return html;
|
|
67
|
+
}
|
|
68
|
+
// #endregion
|
|
69
|
+
// #region Block parser (single pass)
|
|
70
|
+
/** Block-level HTML tags that start a raw HTML block (passthrough, no <p>/<br>). */
|
|
71
|
+
static { this.htmlBlockTags = new Set([
|
|
72
|
+
'address', 'article', 'aside', 'blockquote', 'caption', 'colgroup', 'col',
|
|
73
|
+
'dd', 'details', 'dialog', 'div', 'dl', 'dt', 'fieldset', 'figcaption',
|
|
74
|
+
'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header',
|
|
75
|
+
'hr', 'iframe', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary',
|
|
76
|
+
'table', 'tbody', 'td', 'tfoot', 'thead', 'th', 'tr', 'ul',
|
|
77
|
+
]); }
|
|
78
|
+
/** Void/self-contained tags: a single tag, no closing tag expected. */
|
|
79
|
+
static { this.htmlVoidTags = new Set(['hr', 'col', 'img', 'input']); }
|
|
80
|
+
/**
|
|
81
|
+
* Precompiled: first block-level tag occurrence anywhere in a line.
|
|
82
|
+
* Anchored on the '<' literal, so scanning is cheap; the lookahead
|
|
83
|
+
* prevents partial matches ('<td' must not match inside '<tdx').
|
|
84
|
+
*/
|
|
85
|
+
static { this.htmlBlockScanRe = new RegExp(`<(\\/?)(${[...MarkdownUtils.htmlBlockTags].join('|')})(?=[\\s/>])`, 'gi'); }
|
|
86
|
+
/** Result of scanning a line for the start of a raw HTML block. */
|
|
87
|
+
static findHtmlBlockStart(line) {
|
|
88
|
+
// HTML comment candidate (skipping occurrences inside inline code spans)
|
|
89
|
+
let comment = line.indexOf('<!--');
|
|
90
|
+
while (comment !== -1 && this.insideCodeSpan(line, comment)) {
|
|
91
|
+
comment = line.indexOf('<!--', comment + 4);
|
|
92
|
+
}
|
|
93
|
+
// Block tag candidate (same code-span guard)
|
|
94
|
+
const re = this.htmlBlockScanRe;
|
|
95
|
+
re.lastIndex = 0;
|
|
96
|
+
let m;
|
|
97
|
+
let tagHit;
|
|
98
|
+
while ((m = re.exec(line)) !== null) {
|
|
99
|
+
if (!this.insideCodeSpan(line, m.index)) {
|
|
100
|
+
tagHit = { index: m.index, tag: m[2].toLowerCase(), isClose: m[1] === '/' };
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (comment !== -1 && (!tagHit || comment < tagHit.index)) {
|
|
105
|
+
return { index: comment, tag: '', isClose: false, isComment: true };
|
|
106
|
+
}
|
|
107
|
+
return tagHit ? { ...tagHit, isComment: false } : undefined;
|
|
108
|
+
}
|
|
109
|
+
/** True when `index` falls inside an inline code span (odd backtick count before it). */
|
|
110
|
+
static insideCodeSpan(line, index) {
|
|
111
|
+
let count = 0;
|
|
112
|
+
for (let i = 0; i < index; i++) {
|
|
113
|
+
if (line.charCodeAt(i) === 96 /* ` */)
|
|
114
|
+
count++;
|
|
115
|
+
}
|
|
116
|
+
return (count & 1) === 1;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* indexOf-based depth scanner (no regex, no allocations): walks `text`
|
|
120
|
+
* adjusting `depth` for <tag ...> / </tag> occurrences of the SAME tag.
|
|
121
|
+
* Self-closing <tag ... /> forms do not alter depth.
|
|
122
|
+
* @returns [newDepth, endIndex] where endIndex is the position right after
|
|
123
|
+
* the '>' that balanced the element, or -1 when still open.
|
|
124
|
+
*/
|
|
125
|
+
static scanHtmlDepth(text, tag, depth) {
|
|
126
|
+
const lower = text.toLowerCase();
|
|
127
|
+
const open = '<' + tag;
|
|
128
|
+
const close = '</' + tag;
|
|
129
|
+
let i = 0;
|
|
130
|
+
while ((i = lower.indexOf('<', i)) !== -1) {
|
|
131
|
+
if (lower.startsWith(close, i) && this.isTagBoundary(lower, i + close.length)) {
|
|
132
|
+
depth--;
|
|
133
|
+
const gt = lower.indexOf('>', i);
|
|
134
|
+
const end = gt === -1 ? lower.length : gt + 1;
|
|
135
|
+
if (depth <= 0)
|
|
136
|
+
return [0, end];
|
|
137
|
+
i = end;
|
|
138
|
+
}
|
|
139
|
+
else if (lower.startsWith(open, i) && this.isTagBoundary(lower, i + open.length)) {
|
|
140
|
+
const gt = lower.indexOf('>', i);
|
|
141
|
+
const selfClosing = gt !== -1 && lower.charCodeAt(gt - 1) === 47 /* / */;
|
|
142
|
+
if (selfClosing) {
|
|
143
|
+
if (depth === 0)
|
|
144
|
+
return [0, gt + 1]; // standalone self-closed element
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
depth++;
|
|
148
|
+
}
|
|
149
|
+
i = gt === -1 ? lower.length : gt + 1;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
i++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return [depth, -1];
|
|
156
|
+
}
|
|
157
|
+
/** A tag token must be followed by whitespace, '/', '>' or end of line. */
|
|
158
|
+
static isTagBoundary(s, idx) {
|
|
159
|
+
if (idx >= s.length)
|
|
160
|
+
return true; // tag opening continues on the next line
|
|
161
|
+
const c = s.charCodeAt(idx);
|
|
162
|
+
return c === 62 /* > */ || c === 47 /* / */ || c === 32 /* space */ || c === 9 /* tab */;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Re-injects the remainder of a partially consumed line so the main loop
|
|
166
|
+
* processes it as markdown. Returns the index the loop should resume from.
|
|
167
|
+
*/
|
|
168
|
+
static pushBack(lines, i, remainder) {
|
|
169
|
+
if (remainder.trim()) {
|
|
170
|
+
lines[i] = remainder;
|
|
171
|
+
return i - 1; // the loop's i++ re-processes the remainder
|
|
172
|
+
}
|
|
173
|
+
return i;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Emits a raw HTML block starting at `hit.index` of lines[i], consuming
|
|
177
|
+
* following lines until the element is balanced. Text after the block on
|
|
178
|
+
* the closing line is pushed back for markdown processing.
|
|
179
|
+
* @returns the index of the last consumed line
|
|
180
|
+
*/
|
|
181
|
+
static emitHtmlBlock(lines, i, hit, out) {
|
|
182
|
+
const line = lines[i];
|
|
183
|
+
// HTML comment: raw until '-->'
|
|
184
|
+
if (hit.isComment) {
|
|
185
|
+
let end = line.indexOf('-->', hit.index);
|
|
186
|
+
if (end !== -1) {
|
|
187
|
+
out.push(line.slice(hit.index, end + 3));
|
|
188
|
+
return this.pushBack(lines, i, line.slice(end + 3));
|
|
189
|
+
}
|
|
190
|
+
out.push(line.slice(hit.index));
|
|
191
|
+
while (++i < lines.length) {
|
|
192
|
+
end = lines[i].indexOf('-->');
|
|
193
|
+
if (end !== -1) {
|
|
194
|
+
out.push(lines[i].slice(0, end + 3));
|
|
195
|
+
return this.pushBack(lines, i, lines[i].slice(end + 3));
|
|
196
|
+
}
|
|
197
|
+
out.push(lines[i]);
|
|
198
|
+
}
|
|
199
|
+
return lines.length - 1; // unterminated: consumed to EOF
|
|
200
|
+
}
|
|
201
|
+
// Stray closing tag or void tag: emit just the tag, resume after '>'
|
|
202
|
+
if (hit.isClose || this.htmlVoidTags.has(hit.tag)) {
|
|
203
|
+
const gt = line.indexOf('>', hit.index);
|
|
204
|
+
const end = gt === -1 ? line.length : gt + 1;
|
|
205
|
+
out.push(line.slice(hit.index, end));
|
|
206
|
+
return this.pushBack(lines, i, line.slice(end));
|
|
207
|
+
}
|
|
208
|
+
// Opening block tag: raw until the element is balanced (depth === 0)
|
|
209
|
+
let [depth, split] = this.scanHtmlDepth(line.slice(hit.index), hit.tag, 0);
|
|
210
|
+
if (split !== -1) {
|
|
211
|
+
out.push(line.slice(hit.index, hit.index + split));
|
|
212
|
+
return this.pushBack(lines, i, line.slice(hit.index + split));
|
|
213
|
+
}
|
|
214
|
+
out.push(line.slice(hit.index));
|
|
215
|
+
while (depth > 0 && ++i < lines.length) {
|
|
216
|
+
[depth, split] = this.scanHtmlDepth(lines[i], hit.tag, depth);
|
|
217
|
+
if (split !== -1) {
|
|
218
|
+
out.push(lines[i].slice(0, split));
|
|
219
|
+
return this.pushBack(lines, i, lines[i].slice(split));
|
|
220
|
+
}
|
|
221
|
+
out.push(lines[i]);
|
|
222
|
+
}
|
|
223
|
+
return Math.min(i, lines.length - 1);
|
|
224
|
+
}
|
|
225
|
+
static parseBlocks(lines, opts) {
|
|
226
|
+
const out = [];
|
|
227
|
+
let paragraph = [];
|
|
228
|
+
const flush = () => {
|
|
229
|
+
if (paragraph.length > 0) {
|
|
230
|
+
const sep = opts.breaks ? '<br>' : ' ';
|
|
231
|
+
out.push(`<p>${paragraph.map(l => this.inline(l, opts)).join(sep)}</p>`);
|
|
232
|
+
paragraph = [];
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
for (let i = 0; i < lines.length; i++) {
|
|
236
|
+
const line = lines[i];
|
|
237
|
+
const trimmed = line.trim();
|
|
238
|
+
// --- Fenced code block -------------------------------------------------
|
|
239
|
+
const fence = trimmed.match(/^```([\w+-]+)?\s*$/);
|
|
240
|
+
if (fence) {
|
|
241
|
+
flush();
|
|
242
|
+
const lang = fence[1] ? ` class="language-${fence[1]}"` : '';
|
|
243
|
+
const code = [];
|
|
244
|
+
i++;
|
|
245
|
+
while (i < lines.length && !/^```\s*$/.test(lines[i].trim())) {
|
|
246
|
+
code.push(lines[i]);
|
|
247
|
+
i++;
|
|
248
|
+
}
|
|
249
|
+
out.push(`<pre><code${lang}>${this.escape(code.join('\n'))}</code></pre>`);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
// --- Blank line --------------------------------------------------------
|
|
253
|
+
if (!trimmed) {
|
|
254
|
+
flush();
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
// --- Raw HTML block passthrough (only with escaping disabled) ------------
|
|
258
|
+
// A block-level HTML element found ANYWHERE in the line starts a raw
|
|
259
|
+
// block: text before it joins the current paragraph, the element is
|
|
260
|
+
// emitted as-is (plain newlines, no <p>, no <br>) until balanced, and
|
|
261
|
+
// any text after the closing tag resumes markdown processing.
|
|
262
|
+
if (!opts.escapeHtml && line.includes('<')) {
|
|
263
|
+
const hit = this.findHtmlBlockStart(line);
|
|
264
|
+
if (hit) {
|
|
265
|
+
const before = line.slice(0, hit.index).trim();
|
|
266
|
+
if (before)
|
|
267
|
+
paragraph.push(before);
|
|
268
|
+
flush();
|
|
269
|
+
i = this.emitHtmlBlock(lines, i, hit, out);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
// --- ATX heading -------------------------------------------------------
|
|
274
|
+
const heading = trimmed.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
275
|
+
if (heading) {
|
|
276
|
+
flush();
|
|
277
|
+
const level = heading[1].length;
|
|
278
|
+
out.push(`<h${level}>${this.inline(heading[2], opts)}</h${level}>`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
// --- Horizontal rule ---------------------------------------------------
|
|
282
|
+
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
283
|
+
flush();
|
|
284
|
+
out.push('<hr>');
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// --- Blockquote (consecutive lines, one level stripped, recursive) ------
|
|
288
|
+
if (/^>\s?/.test(trimmed)) {
|
|
289
|
+
flush();
|
|
290
|
+
const quoted = [];
|
|
291
|
+
while (i < lines.length && /^>\s?/.test(lines[i].trim())) {
|
|
292
|
+
quoted.push(lines[i].trim().replace(/^>\s?/, ''));
|
|
293
|
+
i++;
|
|
294
|
+
}
|
|
295
|
+
i--;
|
|
296
|
+
out.push(`<blockquote>${this.parseBlocks(quoted, opts)}</blockquote>`);
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
// --- Table (header row + separator row) ---------------------------------
|
|
300
|
+
if (trimmed.includes('|') &&
|
|
301
|
+
i + 1 < lines.length &&
|
|
302
|
+
lines[i + 1].includes('-') &&
|
|
303
|
+
/^\|?[\s:\-|]+\|?$/.test(lines[i + 1].trim())) {
|
|
304
|
+
flush();
|
|
305
|
+
const aligns = this.tableAligns(lines[i + 1].trim());
|
|
306
|
+
const head = this.tableRow(trimmed, 'th', aligns, opts);
|
|
307
|
+
const body = [];
|
|
308
|
+
i += 2;
|
|
309
|
+
while (i < lines.length && lines[i].trim().includes('|')) {
|
|
310
|
+
body.push(this.tableRow(lines[i].trim(), 'td', aligns, opts));
|
|
311
|
+
i++;
|
|
312
|
+
}
|
|
313
|
+
i--;
|
|
314
|
+
out.push(`<table>\n<thead>\n${head}\n</thead>` +
|
|
315
|
+
(body.length > 0 ? `\n<tbody>\n${body.join('\n')}\n</tbody>` : '') +
|
|
316
|
+
`\n</table>`);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
// --- List ---------------------------------------------------------------
|
|
320
|
+
if (MarkdownUtils.listItemRe.test(line)) {
|
|
321
|
+
flush();
|
|
322
|
+
const [html, last] = this.parseList(lines, i, opts);
|
|
323
|
+
out.push(html);
|
|
324
|
+
i = last;
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
// --- Plain text: accumulate into current paragraph ----------------------
|
|
328
|
+
paragraph.push(trimmed);
|
|
329
|
+
}
|
|
330
|
+
flush();
|
|
331
|
+
return out.join('\n');
|
|
332
|
+
}
|
|
333
|
+
// #endregion
|
|
334
|
+
// #region Lists
|
|
335
|
+
/** Matches "- item", "* item", "+ item", "1. item", "1) item" with leading indent. */
|
|
336
|
+
static { this.listItemRe = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/; }
|
|
337
|
+
/**
|
|
338
|
+
* Gathers the contiguous list block starting at `start`, builds it (with
|
|
339
|
+
* nesting) and returns [html, indexOfLastConsumedLine].
|
|
340
|
+
*/
|
|
341
|
+
static parseList(lines, start, opts) {
|
|
342
|
+
const block = [];
|
|
343
|
+
let i = start;
|
|
344
|
+
while (i < lines.length) {
|
|
345
|
+
const l = lines[i];
|
|
346
|
+
if (!l.trim())
|
|
347
|
+
break; // blank line ends the list
|
|
348
|
+
if (this.listItemRe.test(l) || /^\s{2,}\S/.test(l)) {
|
|
349
|
+
block.push(l);
|
|
350
|
+
i++;
|
|
351
|
+
}
|
|
352
|
+
else
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
return [this.buildList(block, opts), i - 1];
|
|
356
|
+
}
|
|
357
|
+
static buildList(block, opts) {
|
|
358
|
+
const first = block[0].match(this.listItemRe);
|
|
359
|
+
const baseIndent = first[1].length;
|
|
360
|
+
const ordered = /^\d/.test(first[2]);
|
|
361
|
+
const startNum = ordered ? parseInt(first[2], 10) : 1;
|
|
362
|
+
const items = [];
|
|
363
|
+
for (const l of block) {
|
|
364
|
+
const m = l.match(this.listItemRe);
|
|
365
|
+
if (m && m[1].length <= baseIndent) {
|
|
366
|
+
items.push({ text: m[3], childLines: [] });
|
|
367
|
+
}
|
|
368
|
+
else if (items.length > 0) {
|
|
369
|
+
// Continuation or nested content: strip one nesting level of indent
|
|
370
|
+
items[items.length - 1].childLines.push(l.length > baseIndent + 2 ? l.slice(baseIndent + 2) : l.trim());
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const lis = items.map(item => {
|
|
374
|
+
let inner;
|
|
375
|
+
// GFM task list: - [ ] / - [x]
|
|
376
|
+
const task = item.text.match(/^\[([ xX])\]\s+(.*)$/);
|
|
377
|
+
if (task) {
|
|
378
|
+
const checked = task[1] !== ' ' ? ' checked' : '';
|
|
379
|
+
inner = `<input type="checkbox" disabled${checked}> ${this.inline(task[2], opts)}`;
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
inner = this.inline(item.text, opts);
|
|
383
|
+
}
|
|
384
|
+
if (item.childLines.length > 0) {
|
|
385
|
+
inner += '\n' + this.parseBlocks(item.childLines, opts);
|
|
386
|
+
}
|
|
387
|
+
return `<li>${inner}</li>`;
|
|
388
|
+
});
|
|
389
|
+
const tag = ordered ? 'ol' : 'ul';
|
|
390
|
+
const startAttr = ordered && startNum > 1 ? ` start="${startNum}"` : '';
|
|
391
|
+
return `<${tag}${startAttr}>\n${lis.join('\n')}\n</${tag}>`;
|
|
392
|
+
}
|
|
393
|
+
// #endregion
|
|
394
|
+
// #region Tables
|
|
395
|
+
static tableAligns(separator) {
|
|
396
|
+
return separator
|
|
397
|
+
.replace(/^\||\|$/g, '')
|
|
398
|
+
.split('|')
|
|
399
|
+
.map(cell => {
|
|
400
|
+
const t = cell.trim();
|
|
401
|
+
if (t.startsWith(':') && t.endsWith(':'))
|
|
402
|
+
return 'center';
|
|
403
|
+
if (t.endsWith(':'))
|
|
404
|
+
return 'right';
|
|
405
|
+
if (t.startsWith(':'))
|
|
406
|
+
return 'left';
|
|
407
|
+
return undefined;
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
static tableRow(row, tag, aligns, opts) {
|
|
411
|
+
const cells = row.replace(/^\||\|$/g, '').split('|').map(c => c.trim());
|
|
412
|
+
const cellsHtml = cells
|
|
413
|
+
.map((cell, j) => {
|
|
414
|
+
const align = aligns[j] ? ` align="${aligns[j]}"` : '';
|
|
415
|
+
return `<${tag}${align}>${this.inline(cell, opts)}</${tag}>`;
|
|
416
|
+
})
|
|
417
|
+
.join('');
|
|
418
|
+
return `<tr>${cellsHtml}</tr>`;
|
|
419
|
+
}
|
|
420
|
+
// #endregion
|
|
421
|
+
// #region Inline formatting
|
|
422
|
+
/**
|
|
423
|
+
* Applies inline markdown to a single text segment.
|
|
424
|
+
* Generated HTML (code, links, images, autolinks) is stashed behind \u0000
|
|
425
|
+
* placeholders so later regex passes can never corrupt it.
|
|
426
|
+
*/
|
|
427
|
+
static inline(text, opts) {
|
|
428
|
+
const store = [];
|
|
429
|
+
const stash = (html) => `\u0000${store.push(html) - 1}\u0000`;
|
|
430
|
+
// 1. Inline code: stash first so later passes can't touch it.
|
|
431
|
+
// Content is ALWAYS escaped so <, >, & inside code survive in the DOM
|
|
432
|
+
// (the browser renders entities back to the original characters).
|
|
433
|
+
let s = text.replace(/`([^`]+)`/g, (_m, code) => stash(`<code>${this.escape(code)}</code>`));
|
|
434
|
+
// 2. Escape raw HTML as entities (opt-in only)
|
|
435
|
+
if (opts.escapeHtml)
|
|
436
|
+
s = this.escape(s);
|
|
437
|
+
// 3. Images: 
|
|
438
|
+
s = s.replace(/!\[([^\]]*)\]\(\s*([^)\s]+)(?:\s+(?:"|")(.+?)(?:"|"))?\s*\)/g, (_m, alt, src, title) => {
|
|
439
|
+
const url = this.safeUrl(src);
|
|
440
|
+
if (!url)
|
|
441
|
+
return alt;
|
|
442
|
+
const t = title ? ` title="${this.escapeAttr(title)}"` : '';
|
|
443
|
+
return stash(`<img src="${url}" alt="${this.escapeAttr(alt)}"${t} loading="lazy">`);
|
|
444
|
+
});
|
|
445
|
+
// 4. Links: [text](url "title")
|
|
446
|
+
s = s.replace(/\[([^\]]+)\]\(\s*([^)\s]+)(?:\s+(?:"|")(.+?)(?:"|"))?\s*\)/g, (_m, txt, href, title) => {
|
|
447
|
+
const url = this.safeUrl(href);
|
|
448
|
+
if (!url)
|
|
449
|
+
return txt;
|
|
450
|
+
const t = title ? ` title="${this.escapeAttr(title)}"` : '';
|
|
451
|
+
return stash(`<a href="${url}"${t} target="_blank" rel="noopener noreferrer">${txt}</a>`);
|
|
452
|
+
});
|
|
453
|
+
// 5. Autolink remaining bare URLs (markdown links are already stashed)
|
|
454
|
+
s = s.replace(/https?:\/\/[^\s<>"']+/g, url => stash(`<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`));
|
|
455
|
+
// 6. Bold / italic / strikethrough.
|
|
456
|
+
// Delimiters must hug the text; underscore emphasis only at word
|
|
457
|
+
// boundaries so identifiers like snake_case are left untouched.
|
|
458
|
+
s = s.replace(/\*\*\*(?=\S)([\s\S]*?\S)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
459
|
+
s = s.replace(/(?<![\w*])___(?=\S)([\s\S]*?\S)___(?![\w*])/g, '<strong><em>$1</em></strong>');
|
|
460
|
+
s = s.replace(/\*\*(?=\S)([\s\S]*?\S)\*\*/g, '<strong>$1</strong>');
|
|
461
|
+
s = s.replace(/(?<![\w*])__(?=\S)([\s\S]*?\S)__(?![\w*])/g, '<strong>$1</strong>');
|
|
462
|
+
s = s.replace(/\*(?=\S)([^*\n]*?\S)\*/g, '<em>$1</em>');
|
|
463
|
+
s = s.replace(/(?<![\w_])_(?=\S)([^_\n]*?\S)_(?![\w_])/g, '<em>$1</em>');
|
|
464
|
+
s = s.replace(/~~(?=\S)([\s\S]*?\S)~~/g, '<del>$1</del>');
|
|
465
|
+
// 7. Restore stashed HTML
|
|
466
|
+
return s.replace(/\u0000(\d+)\u0000/g, (_m, idx) => store[Number(idx)] ?? '');
|
|
467
|
+
}
|
|
468
|
+
// #endregion
|
|
469
|
+
// #region Security helpers
|
|
470
|
+
/** Escapes quotes for safe interpolation inside a double-quoted HTML attribute. */
|
|
471
|
+
static escapeAttr(s) {
|
|
472
|
+
return s.replace(/&(?!(?:amp|lt|gt|quot|#\d+);)/g, '&').replace(/"/g, '"');
|
|
473
|
+
}
|
|
474
|
+
/** Escapes &, <, > and " for safe HTML interpolation. */
|
|
475
|
+
static escape(s) {
|
|
476
|
+
return s
|
|
477
|
+
.replace(/&/g, '&')
|
|
478
|
+
.replace(/</g, '<')
|
|
479
|
+
.replace(/>/g, '>')
|
|
480
|
+
.replace(/"/g, '"');
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Returns a sanitized URL or undefined when the scheme is dangerous.
|
|
484
|
+
* Blocks javascript:, vbscript: and data: (also when obfuscated with
|
|
485
|
+
* whitespace/control characters, e.g. "java\tscript:").
|
|
486
|
+
*/
|
|
487
|
+
static safeUrl(url) {
|
|
488
|
+
const compact = url.replace(/[\s\u0000-\u001f]/g, '');
|
|
489
|
+
if (/^(javascript|vbscript|data):/i.test(compact))
|
|
490
|
+
return undefined;
|
|
491
|
+
return url.replace(/"/g, '%22');
|
|
492
|
+
}
|
|
493
|
+
// #endregion
|
|
494
|
+
// #region Clipboard
|
|
495
|
+
/**
|
|
496
|
+
* Copies markdown content to the clipboard in two flavors:
|
|
497
|
+
* - text/html : the rendered HTML (rich paste into Word, Outlook, Gmail...)
|
|
498
|
+
* - text/plain : the original markdown source (paste into editors, IDEs...)
|
|
499
|
+
* @param markdown : the markdown source
|
|
500
|
+
* @param options : conversion options for the HTML flavor
|
|
501
|
+
* @returns : true on success
|
|
502
|
+
*/
|
|
503
|
+
static async copyToClipboard(markdown, options) {
|
|
504
|
+
if (!markdown || typeof navigator === 'undefined' || !navigator.clipboard)
|
|
505
|
+
return false;
|
|
506
|
+
try {
|
|
507
|
+
if (typeof ClipboardItem !== 'undefined') {
|
|
508
|
+
const html = this.toHtml(markdown, options);
|
|
509
|
+
await navigator.clipboard.write([
|
|
510
|
+
new ClipboardItem({
|
|
511
|
+
'text/html': new Blob([html], { type: 'text/html' }),
|
|
512
|
+
'text/plain': new Blob([markdown], { type: 'text/plain' }),
|
|
513
|
+
}),
|
|
514
|
+
]);
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
// Older engines: plain text only
|
|
518
|
+
await navigator.clipboard.writeText(markdown);
|
|
519
|
+
}
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
// Clipboard API requires a secure context and user activation
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Copies plain text (e.g. the content of a single code block) to the clipboard.
|
|
529
|
+
* @param text : the text to copy
|
|
530
|
+
* @returns : true on success
|
|
531
|
+
*/
|
|
532
|
+
static async copyText(text) {
|
|
533
|
+
if (!text || typeof navigator === 'undefined' || !navigator.clipboard)
|
|
534
|
+
return false;
|
|
535
|
+
try {
|
|
536
|
+
await navigator.clipboard.writeText(text);
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Extracts the visible plain text from a rendered element
|
|
545
|
+
* (what the user sees, entities already decoded by the browser).
|
|
546
|
+
* @param element : the element hosting the rendered markdown
|
|
547
|
+
* @returns : the plain text
|
|
548
|
+
*/
|
|
549
|
+
static elementToText(element) {
|
|
550
|
+
return element.innerText ?? element.textContent ?? '';
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Pipe that converts a Markdown string to sanitized HTML using `MarkdownUtils.toHtml`.
|
|
556
|
+
*
|
|
557
|
+
* Usage: `{{ text | formatMarkdown }}`
|
|
558
|
+
*/
|
|
559
|
+
class FormatMarkdownPipe {
|
|
560
|
+
constructor() {
|
|
561
|
+
this.sanitizer = inject(DomSanitizer);
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Transforms a Markdown string into sanitized HTML.
|
|
565
|
+
* @param value - The Markdown input to convert. Treated as an empty string when `undefined`.
|
|
566
|
+
* @returns A `SafeHtml` value that can be rendered with `[innerHTML]`.
|
|
567
|
+
*/
|
|
568
|
+
transform(value) {
|
|
569
|
+
return this.sanitizer.bypassSecurityTrustHtml(MarkdownUtils.toHtml(value ?? '', { escapeHtml: false }));
|
|
570
|
+
}
|
|
571
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: FormatMarkdownPipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe }); }
|
|
572
|
+
static { this.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "22.1.3", ngImport: i0, type: FormatMarkdownPipe, isStandalone: true, name: "formatMarkdown" }); }
|
|
573
|
+
}
|
|
574
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.3", ngImport: i0, type: FormatMarkdownPipe, decorators: [{
|
|
575
|
+
type: Pipe,
|
|
576
|
+
args: [{
|
|
577
|
+
name: 'formatMarkdown',
|
|
578
|
+
standalone: true
|
|
579
|
+
}]
|
|
580
|
+
}] });
|
|
581
|
+
|
|
582
|
+
/*
|
|
583
|
+
* Public API Surface of @arsedizioni/ars-utils/core.markdown
|
|
584
|
+
*/
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Generated bundle index. Do not edit.
|
|
588
|
+
*/
|
|
589
|
+
|
|
590
|
+
export { FormatMarkdownPipe, MarkdownUtils };
|
|
591
|
+
//# sourceMappingURL=arsedizioni-ars-utils-core.markdown.mjs.map
|