expensify-common 2.0.200 → 2.0.201
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/dist/ExpensiMark.d.ts +1 -1
- package/dist/ExpensiMark.js +266 -4
- package/dist/esm/ExpensiMark.d.ts +1 -1
- package/dist/esm/ExpensiMark.js +266 -4
- package/package.json +1 -1
package/dist/ExpensiMark.d.ts
CHANGED
|
@@ -133,7 +133,7 @@ export default class ExpensiMark {
|
|
|
133
133
|
/**
|
|
134
134
|
* Checks matched URLs for validity and replace valid links with html elements
|
|
135
135
|
*/
|
|
136
|
-
modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn): string;
|
|
136
|
+
modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn, shouldScanForUrls?: boolean): string;
|
|
137
137
|
/**
|
|
138
138
|
* Checks matched Emails for validity and replace valid links with html elements
|
|
139
139
|
*/
|
package/dist/ExpensiMark.js
CHANGED
|
@@ -43,9 +43,23 @@ const UrlPatterns = __importStar(require("./Url"));
|
|
|
43
43
|
const Logger_1 = __importDefault(require("./Logger"));
|
|
44
44
|
const Utils = __importStar(require("./utils"));
|
|
45
45
|
const EXTRAS_DEFAULT = {};
|
|
46
|
+
// These constants represent the ASCII ranges for digits (0-9) and letters (A-Z, a-z).
|
|
47
|
+
const ASCII_DIGIT_START = '0'.charCodeAt(0);
|
|
48
|
+
const ASCII_DIGIT_END = '9'.charCodeAt(0);
|
|
49
|
+
const ASCII_UPPERCASE_START = 'A'.charCodeAt(0);
|
|
50
|
+
const ASCII_UPPERCASE_END = 'Z'.charCodeAt(0);
|
|
51
|
+
const ASCII_LOWERCASE_START = 'a'.charCodeAt(0);
|
|
52
|
+
const ASCII_LOWERCASE_END = 'z'.charCodeAt(0);
|
|
53
|
+
const ASCII_WHITESPACE_END = ' '.charCodeAt(0);
|
|
54
|
+
const NON_BREAKING_SPACE_CODE = 160;
|
|
55
|
+
const URL_PROTOCOLS = ['https://', 'http://', 'ftps://', 'ftp://'];
|
|
56
|
+
const URL_CANDIDATE_PREFIX_CHARACTERS = '@_*~';
|
|
57
|
+
const PROTECTED_TAG_NAMES = new Set(['a', 'code', 'pre', 'video']);
|
|
46
58
|
const MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
|
|
47
59
|
const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
|
|
48
60
|
const MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join('|')}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
|
|
61
|
+
const BOLD_MARKDOWN_REGEX = /(?<!<[^>]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?<!\s))\*\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g;
|
|
62
|
+
const STRIKETHROUGH_MARKDOWN_REGEX = /(?<!<[^>]*)\B~((?![\s~])[\s\S]*?[^\s~](?<!\s))~\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g;
|
|
49
63
|
const SLACK_SPAN_NEW_LINE_TAG = '<span class="c-mrkdwn__br" data-stringify-type="paragraph-break" style="box-sizing: inherit; display: block; height: unset;"></span>';
|
|
50
64
|
// Preserve VirtualCFO chart blocks by matching the outer <VictoryChart> container.
|
|
51
65
|
// This captures all nested Victory components and prevents markup escaping during conversion.
|
|
@@ -85,6 +99,236 @@ function replaceTextWithExtras(text, regexp, extras, replacement) {
|
|
|
85
99
|
}
|
|
86
100
|
return text.replace(regexp, replacement);
|
|
87
101
|
}
|
|
102
|
+
/** Returns whether the character is an ASCII letter or digit. */
|
|
103
|
+
function isAsciiAlphaNumeric(character) {
|
|
104
|
+
if (!character) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
const code = character.charCodeAt(0);
|
|
108
|
+
return ((code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END) ||
|
|
109
|
+
(code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END) ||
|
|
110
|
+
(code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END));
|
|
111
|
+
}
|
|
112
|
+
/** Returns whether the character is an ASCII letter, digit, or underscore. */
|
|
113
|
+
function isWordCharacter(character) {
|
|
114
|
+
return character === '_' || isAsciiAlphaNumeric(character);
|
|
115
|
+
}
|
|
116
|
+
/** Returns whether the marker at this position can open a bold range. */
|
|
117
|
+
function canOpenBoldMarkdown(text, position, isProtected) {
|
|
118
|
+
if (isProtected) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const nextCharacter = text[position + 1];
|
|
122
|
+
if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith('</em', position + 1)) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
const previousCharacter = text[position - 1];
|
|
126
|
+
if (!isWordCharacter(previousCharacter)) {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
return previousCharacter === '_' && !isWordCharacter(text[position - 2]);
|
|
130
|
+
}
|
|
131
|
+
/** Returns whether the marker at this position can open a strikethrough range. */
|
|
132
|
+
function canOpenStrikethroughMarkdown(text, position) {
|
|
133
|
+
if (isWordCharacter(text[position - 1])) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const nextCharacter = text[position + 1];
|
|
137
|
+
return !!nextCharacter && !/\s|~/.test(nextCharacter);
|
|
138
|
+
}
|
|
139
|
+
/** Returns whether the marker at this position can close a bold or strikethrough range. */
|
|
140
|
+
function canCloseMarkdown(text, position, marker) {
|
|
141
|
+
const previousCharacter = text[position - 1];
|
|
142
|
+
return !!previousCharacter && !/\s/.test(previousCharacter) && previousCharacter !== marker && !isWordCharacter(text[position + 1]);
|
|
143
|
+
}
|
|
144
|
+
/** Records when scanning enters or leaves <a>, <code>, <pre>, or <video>, then returns the character after the tag. */
|
|
145
|
+
function updateProtectedTagStack(text, tagStart, protectedTags) {
|
|
146
|
+
var _a, _b;
|
|
147
|
+
const tagEnd = text.indexOf('>', tagStart + 1);
|
|
148
|
+
if (tagEnd === -1) {
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
const tag = text.slice(tagStart + 1, tagEnd).trim();
|
|
152
|
+
const isClosingTag = tag.startsWith('/');
|
|
153
|
+
const tagName = (_b = (_a = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase();
|
|
154
|
+
if (tagName && PROTECTED_TAG_NAMES.has(tagName)) {
|
|
155
|
+
if (isClosingTag) {
|
|
156
|
+
const matchingTagIndex = protectedTags.lastIndexOf(tagName);
|
|
157
|
+
if (matchingTagIndex !== -1) {
|
|
158
|
+
protectedTags.splice(matchingTagIndex, 1);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (!tag.endsWith('/')) {
|
|
162
|
+
protectedTags.push(tagName);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return tagEnd + 1;
|
|
166
|
+
}
|
|
167
|
+
/** Returns whether the character can be part of a hostname such as example.com. */
|
|
168
|
+
function isHostnameCharacter(character) {
|
|
169
|
+
return !!character && (isAsciiAlphaNumeric(character) || character === '-' || character === '.');
|
|
170
|
+
}
|
|
171
|
+
/** Returns whether the character is whitespace that ends a possible URL. */
|
|
172
|
+
function isUrlBoundarySpace(character) {
|
|
173
|
+
const code = character.charCodeAt(0);
|
|
174
|
+
return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE;
|
|
175
|
+
}
|
|
176
|
+
/** Returns the supported URL protocol that starts at this position, if one exists. */
|
|
177
|
+
function getProtocolAt(text, position) {
|
|
178
|
+
var _a;
|
|
179
|
+
const firstCharacter = (_a = text[position]) === null || _a === void 0 ? void 0 : _a.toLowerCase();
|
|
180
|
+
if (firstCharacter !== 'h' && firstCharacter !== 'f') {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol);
|
|
184
|
+
}
|
|
185
|
+
/** Reads the text after the dot in example.com and returns where the hostname ends. */
|
|
186
|
+
function findHostnameEnd(text, hostnameStart, dotPosition) {
|
|
187
|
+
let hostnameEnd = dotPosition + 1;
|
|
188
|
+
while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === '-')) {
|
|
189
|
+
hostnameEnd++;
|
|
190
|
+
}
|
|
191
|
+
if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
return hostnameEnd;
|
|
195
|
+
}
|
|
196
|
+
/** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */
|
|
197
|
+
function extendUrlCandidateBoundaries(text, start, end) {
|
|
198
|
+
let candidateStart = start;
|
|
199
|
+
while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) {
|
|
200
|
+
candidateStart--;
|
|
201
|
+
}
|
|
202
|
+
let candidateEnd = end;
|
|
203
|
+
while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== '<') {
|
|
204
|
+
candidateEnd++;
|
|
205
|
+
}
|
|
206
|
+
return { start: candidateStart, end: candidateEnd };
|
|
207
|
+
}
|
|
208
|
+
/** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves validity to the existing regex. */
|
|
209
|
+
function findUrlCandidates(text) {
|
|
210
|
+
const candidates = [];
|
|
211
|
+
const protectedTags = [];
|
|
212
|
+
let index = 0;
|
|
213
|
+
let hostnameRunStart = 0;
|
|
214
|
+
while (index < text.length) {
|
|
215
|
+
if (text[index] === '<') {
|
|
216
|
+
const nextIndex = updateProtectedTagStack(text, index, protectedTags);
|
|
217
|
+
if (nextIndex === undefined) {
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
index = nextIndex;
|
|
221
|
+
hostnameRunStart = index;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (protectedTags.length > 0) {
|
|
225
|
+
index++;
|
|
226
|
+
hostnameRunStart = index;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const matchedProtocol = getProtocolAt(text, index);
|
|
230
|
+
if (matchedProtocol) {
|
|
231
|
+
const candidate = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length);
|
|
232
|
+
candidates.push(candidate);
|
|
233
|
+
index = candidate.end;
|
|
234
|
+
hostnameRunStart = candidate.end;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!isHostnameCharacter(text[index])) {
|
|
238
|
+
hostnameRunStart = index + 1;
|
|
239
|
+
index++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (text[index] !== '.') {
|
|
243
|
+
index++;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index);
|
|
247
|
+
if (hostnameEnd === undefined) {
|
|
248
|
+
index++;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd);
|
|
252
|
+
candidates.push(candidate);
|
|
253
|
+
index = candidate.end;
|
|
254
|
+
hostnameRunStart = candidate.end;
|
|
255
|
+
}
|
|
256
|
+
return candidates;
|
|
257
|
+
}
|
|
258
|
+
/** Finds possible bold or strikethrough pairs, preserves marker order inside protected tags, and runs the existing regex only on each candidate. */
|
|
259
|
+
function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) {
|
|
260
|
+
if (!text.includes(marker)) {
|
|
261
|
+
return text;
|
|
262
|
+
}
|
|
263
|
+
const markers = [];
|
|
264
|
+
const protectedTags = [];
|
|
265
|
+
let index = 0;
|
|
266
|
+
while (index < text.length) {
|
|
267
|
+
if (text[index] === '<') {
|
|
268
|
+
const nextIndex = updateProtectedTagStack(text, index, protectedTags);
|
|
269
|
+
if (nextIndex === undefined) {
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
index = nextIndex;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// Keep protected markers in order, but remember that they cannot be replaced.
|
|
276
|
+
if (text[index] === marker) {
|
|
277
|
+
markers.push({ position: index, isProtected: protectedTags.length > 0 });
|
|
278
|
+
}
|
|
279
|
+
index++;
|
|
280
|
+
}
|
|
281
|
+
// A Markdown range needs both an opening and closing marker, such as the two * characters in *bold*.
|
|
282
|
+
if (markers.length < 2) {
|
|
283
|
+
return text;
|
|
284
|
+
}
|
|
285
|
+
const output = [];
|
|
286
|
+
const candidateRegex = regexp;
|
|
287
|
+
let outputStart = 0;
|
|
288
|
+
let openingMarker;
|
|
289
|
+
for (const currentMarker of markers) {
|
|
290
|
+
const markerPosition = currentMarker.position;
|
|
291
|
+
if (openingMarker === undefined) {
|
|
292
|
+
openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : undefined;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (openingMarker.isProtected) {
|
|
299
|
+
openingMarker = undefined;
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const openingPosition = openingMarker.position;
|
|
303
|
+
const prefixLength = openingPosition > 0 ? 1 : 0;
|
|
304
|
+
const suffixLength = markerPosition + 1 < text.length ? 1 : 0;
|
|
305
|
+
const candidateStart = openingPosition - prefixLength;
|
|
306
|
+
const candidateEnd = markerPosition + 1 + suffixLength;
|
|
307
|
+
const candidate = text.slice(candidateStart, candidateEnd);
|
|
308
|
+
candidateRegex.lastIndex = 0;
|
|
309
|
+
const candidateMatch = candidateRegex.exec(candidate);
|
|
310
|
+
if (!candidateMatch) {
|
|
311
|
+
openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : undefined;
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
candidateRegex.lastIndex = 0;
|
|
315
|
+
const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement);
|
|
316
|
+
if (replacedCandidate !== candidate) {
|
|
317
|
+
const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length;
|
|
318
|
+
output.push(text.slice(outputStart, openingPosition));
|
|
319
|
+
output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd));
|
|
320
|
+
outputStart = markerPosition + 1;
|
|
321
|
+
openingMarker = undefined;
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
openingMarker = undefined;
|
|
325
|
+
}
|
|
326
|
+
if (output.length === 0) {
|
|
327
|
+
return text;
|
|
328
|
+
}
|
|
329
|
+
output.push(text.slice(outputStart));
|
|
330
|
+
return output.join('');
|
|
331
|
+
}
|
|
88
332
|
/**
|
|
89
333
|
* replace block element with '\n' if :
|
|
90
334
|
* 1. We have text within the element.
|
|
@@ -547,7 +791,7 @@ class ExpensiMark {
|
|
|
547
791
|
name: 'autolink',
|
|
548
792
|
process: (textToProcess, replacement) => {
|
|
549
793
|
const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gi');
|
|
550
|
-
return this.modifyTextForUrlLinks(regex, textToProcess, replacement);
|
|
794
|
+
return this.modifyTextForUrlLinks(regex, textToProcess, replacement, true);
|
|
551
795
|
},
|
|
552
796
|
replacement: (_extras, _match, g1, g2) => {
|
|
553
797
|
const href = str_1.default.sanitizeURL(g2);
|
|
@@ -653,7 +897,7 @@ class ExpensiMark {
|
|
|
653
897
|
// \B will match everything that \b doesn't, so it works
|
|
654
898
|
// for * and ~: https://www.rexegg.com/regex-boundaries.html#notb
|
|
655
899
|
name: 'bold',
|
|
656
|
-
|
|
900
|
+
process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, '*', canOpenBoldMarkdown),
|
|
657
901
|
replacement: (_extras, match, g1, g2) => {
|
|
658
902
|
if (g1.includes('_')) {
|
|
659
903
|
return `${g1}<strong>${g2}</strong>`;
|
|
@@ -663,7 +907,7 @@ class ExpensiMark {
|
|
|
663
907
|
},
|
|
664
908
|
{
|
|
665
909
|
name: 'strikethrough',
|
|
666
|
-
|
|
910
|
+
process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, '~', canOpenStrikethroughMarkdown),
|
|
667
911
|
replacement: (_extras, match, g1) => (g1.includes('</pre>') || containsNonPairTag(g1) ? match : `<del>${g1}</del>`),
|
|
668
912
|
},
|
|
669
913
|
{
|
|
@@ -1111,7 +1355,25 @@ class ExpensiMark {
|
|
|
1111
1355
|
/**
|
|
1112
1356
|
* Checks matched URLs for validity and replace valid links with html elements
|
|
1113
1357
|
*/
|
|
1114
|
-
modifyTextForUrlLinks(regex, textToCheck, replacement) {
|
|
1358
|
+
modifyTextForUrlLinks(regex, textToCheck, replacement, shouldScanForUrls = false) {
|
|
1359
|
+
if (shouldScanForUrls) {
|
|
1360
|
+
const candidates = findUrlCandidates(textToCheck);
|
|
1361
|
+
if (candidates.length === 0) {
|
|
1362
|
+
return textToCheck;
|
|
1363
|
+
}
|
|
1364
|
+
const output = [];
|
|
1365
|
+
const candidateRegex = regex;
|
|
1366
|
+
let outputStart = 0;
|
|
1367
|
+
for (const { start, end } of candidates) {
|
|
1368
|
+
const candidate = textToCheck.slice(start, end);
|
|
1369
|
+
candidateRegex.lastIndex = 0;
|
|
1370
|
+
output.push(textToCheck.slice(outputStart, start));
|
|
1371
|
+
output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement));
|
|
1372
|
+
outputStart = end;
|
|
1373
|
+
}
|
|
1374
|
+
output.push(textToCheck.slice(outputStart));
|
|
1375
|
+
return output.join('');
|
|
1376
|
+
}
|
|
1115
1377
|
let match = regex.exec(textToCheck);
|
|
1116
1378
|
let replacedText = '';
|
|
1117
1379
|
let startIndex = 0;
|
|
@@ -133,7 +133,7 @@ export default class ExpensiMark {
|
|
|
133
133
|
/**
|
|
134
134
|
* Checks matched URLs for validity and replace valid links with html elements
|
|
135
135
|
*/
|
|
136
|
-
modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn): string;
|
|
136
|
+
modifyTextForUrlLinks(regex: RegExp, textToCheck: string, replacement: ReplacementFn, shouldScanForUrls?: boolean): string;
|
|
137
137
|
/**
|
|
138
138
|
* Checks matched Emails for validity and replace valid links with html elements
|
|
139
139
|
*/
|
package/dist/esm/ExpensiMark.js
CHANGED
|
@@ -5,9 +5,23 @@ import * as UrlPatterns from './Url';
|
|
|
5
5
|
import Logger from './Logger';
|
|
6
6
|
import * as Utils from './utils';
|
|
7
7
|
const EXTRAS_DEFAULT = {};
|
|
8
|
+
// These constants represent the ASCII ranges for digits (0-9) and letters (A-Z, a-z).
|
|
9
|
+
const ASCII_DIGIT_START = '0'.charCodeAt(0);
|
|
10
|
+
const ASCII_DIGIT_END = '9'.charCodeAt(0);
|
|
11
|
+
const ASCII_UPPERCASE_START = 'A'.charCodeAt(0);
|
|
12
|
+
const ASCII_UPPERCASE_END = 'Z'.charCodeAt(0);
|
|
13
|
+
const ASCII_LOWERCASE_START = 'a'.charCodeAt(0);
|
|
14
|
+
const ASCII_LOWERCASE_END = 'z'.charCodeAt(0);
|
|
15
|
+
const ASCII_WHITESPACE_END = ' '.charCodeAt(0);
|
|
16
|
+
const NON_BREAKING_SPACE_CODE = 160;
|
|
17
|
+
const URL_PROTOCOLS = ['https://', 'http://', 'ftps://', 'ftp://'];
|
|
18
|
+
const URL_CANDIDATE_PREFIX_CHARACTERS = '@_*~';
|
|
19
|
+
const PROTECTED_TAG_NAMES = new Set(['a', 'code', 'pre', 'video']);
|
|
8
20
|
const MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
|
|
9
21
|
const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
|
|
10
22
|
const MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join('|')}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
|
|
23
|
+
const BOLD_MARKDOWN_REGEX = /(?<!<[^>]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?<!\s))\*\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g;
|
|
24
|
+
const STRIKETHROUGH_MARKDOWN_REGEX = /(?<!<[^>]*)\B~((?![\s~])[\s\S]*?[^\s~](?<!\s))~\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g;
|
|
11
25
|
const SLACK_SPAN_NEW_LINE_TAG = '<span class="c-mrkdwn__br" data-stringify-type="paragraph-break" style="box-sizing: inherit; display: block; height: unset;"></span>';
|
|
12
26
|
// Preserve VirtualCFO chart blocks by matching the outer <VictoryChart> container.
|
|
13
27
|
// This captures all nested Victory components and prevents markup escaping during conversion.
|
|
@@ -47,6 +61,236 @@ function replaceTextWithExtras(text, regexp, extras, replacement) {
|
|
|
47
61
|
}
|
|
48
62
|
return text.replace(regexp, replacement);
|
|
49
63
|
}
|
|
64
|
+
/** Returns whether the character is an ASCII letter or digit. */
|
|
65
|
+
function isAsciiAlphaNumeric(character) {
|
|
66
|
+
if (!character) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
const code = character.charCodeAt(0);
|
|
70
|
+
return ((code >= ASCII_DIGIT_START && code <= ASCII_DIGIT_END) ||
|
|
71
|
+
(code >= ASCII_UPPERCASE_START && code <= ASCII_UPPERCASE_END) ||
|
|
72
|
+
(code >= ASCII_LOWERCASE_START && code <= ASCII_LOWERCASE_END));
|
|
73
|
+
}
|
|
74
|
+
/** Returns whether the character is an ASCII letter, digit, or underscore. */
|
|
75
|
+
function isWordCharacter(character) {
|
|
76
|
+
return character === '_' || isAsciiAlphaNumeric(character);
|
|
77
|
+
}
|
|
78
|
+
/** Returns whether the marker at this position can open a bold range. */
|
|
79
|
+
function canOpenBoldMarkdown(text, position, isProtected) {
|
|
80
|
+
if (isProtected) {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
const nextCharacter = text[position + 1];
|
|
84
|
+
if (!nextCharacter || /\s|\*/.test(nextCharacter) || text.startsWith('</em', position + 1)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
const previousCharacter = text[position - 1];
|
|
88
|
+
if (!isWordCharacter(previousCharacter)) {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
return previousCharacter === '_' && !isWordCharacter(text[position - 2]);
|
|
92
|
+
}
|
|
93
|
+
/** Returns whether the marker at this position can open a strikethrough range. */
|
|
94
|
+
function canOpenStrikethroughMarkdown(text, position) {
|
|
95
|
+
if (isWordCharacter(text[position - 1])) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const nextCharacter = text[position + 1];
|
|
99
|
+
return !!nextCharacter && !/\s|~/.test(nextCharacter);
|
|
100
|
+
}
|
|
101
|
+
/** Returns whether the marker at this position can close a bold or strikethrough range. */
|
|
102
|
+
function canCloseMarkdown(text, position, marker) {
|
|
103
|
+
const previousCharacter = text[position - 1];
|
|
104
|
+
return !!previousCharacter && !/\s/.test(previousCharacter) && previousCharacter !== marker && !isWordCharacter(text[position + 1]);
|
|
105
|
+
}
|
|
106
|
+
/** Records when scanning enters or leaves <a>, <code>, <pre>, or <video>, then returns the character after the tag. */
|
|
107
|
+
function updateProtectedTagStack(text, tagStart, protectedTags) {
|
|
108
|
+
var _a, _b;
|
|
109
|
+
const tagEnd = text.indexOf('>', tagStart + 1);
|
|
110
|
+
if (tagEnd === -1) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
const tag = text.slice(tagStart + 1, tagEnd).trim();
|
|
114
|
+
const isClosingTag = tag.startsWith('/');
|
|
115
|
+
const tagName = (_b = (_a = tag.match(/^\/?\s*([a-z][a-z0-9-]*)/i)) === null || _a === void 0 ? void 0 : _a[1]) === null || _b === void 0 ? void 0 : _b.toLowerCase();
|
|
116
|
+
if (tagName && PROTECTED_TAG_NAMES.has(tagName)) {
|
|
117
|
+
if (isClosingTag) {
|
|
118
|
+
const matchingTagIndex = protectedTags.lastIndexOf(tagName);
|
|
119
|
+
if (matchingTagIndex !== -1) {
|
|
120
|
+
protectedTags.splice(matchingTagIndex, 1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else if (!tag.endsWith('/')) {
|
|
124
|
+
protectedTags.push(tagName);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return tagEnd + 1;
|
|
128
|
+
}
|
|
129
|
+
/** Returns whether the character can be part of a hostname such as example.com. */
|
|
130
|
+
function isHostnameCharacter(character) {
|
|
131
|
+
return !!character && (isAsciiAlphaNumeric(character) || character === '-' || character === '.');
|
|
132
|
+
}
|
|
133
|
+
/** Returns whether the character is whitespace that ends a possible URL. */
|
|
134
|
+
function isUrlBoundarySpace(character) {
|
|
135
|
+
const code = character.charCodeAt(0);
|
|
136
|
+
return code <= ASCII_WHITESPACE_END || code === NON_BREAKING_SPACE_CODE;
|
|
137
|
+
}
|
|
138
|
+
/** Returns the supported URL protocol that starts at this position, if one exists. */
|
|
139
|
+
function getProtocolAt(text, position) {
|
|
140
|
+
var _a;
|
|
141
|
+
const firstCharacter = (_a = text[position]) === null || _a === void 0 ? void 0 : _a.toLowerCase();
|
|
142
|
+
if (firstCharacter !== 'h' && firstCharacter !== 'f') {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
return URL_PROTOCOLS.find((protocol) => text.slice(position, position + protocol.length).toLowerCase() === protocol);
|
|
146
|
+
}
|
|
147
|
+
/** Reads the text after the dot in example.com and returns where the hostname ends. */
|
|
148
|
+
function findHostnameEnd(text, hostnameStart, dotPosition) {
|
|
149
|
+
let hostnameEnd = dotPosition + 1;
|
|
150
|
+
while (hostnameEnd < text.length && (isAsciiAlphaNumeric(text[hostnameEnd]) || text[hostnameEnd] === '-')) {
|
|
151
|
+
hostnameEnd++;
|
|
152
|
+
}
|
|
153
|
+
if (hostnameStart === dotPosition || hostnameEnd === dotPosition + 1) {
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
return hostnameEnd;
|
|
157
|
+
}
|
|
158
|
+
/** Expands example.com to include nearby @, *, _, or ~ in any order, plus its path, until whitespace or HTML. */
|
|
159
|
+
function extendUrlCandidateBoundaries(text, start, end) {
|
|
160
|
+
let candidateStart = start;
|
|
161
|
+
while (candidateStart > 0 && URL_CANDIDATE_PREFIX_CHARACTERS.includes(text[candidateStart - 1])) {
|
|
162
|
+
candidateStart--;
|
|
163
|
+
}
|
|
164
|
+
let candidateEnd = end;
|
|
165
|
+
while (candidateEnd < text.length && !isUrlBoundarySpace(text[candidateEnd]) && text[candidateEnd] !== '<') {
|
|
166
|
+
candidateEnd++;
|
|
167
|
+
}
|
|
168
|
+
return { start: candidateStart, end: candidateEnd };
|
|
169
|
+
}
|
|
170
|
+
/** Finds possible URL ranges, skips URL-looking text inside protected tags, and leaves validity to the existing regex. */
|
|
171
|
+
function findUrlCandidates(text) {
|
|
172
|
+
const candidates = [];
|
|
173
|
+
const protectedTags = [];
|
|
174
|
+
let index = 0;
|
|
175
|
+
let hostnameRunStart = 0;
|
|
176
|
+
while (index < text.length) {
|
|
177
|
+
if (text[index] === '<') {
|
|
178
|
+
const nextIndex = updateProtectedTagStack(text, index, protectedTags);
|
|
179
|
+
if (nextIndex === undefined) {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
index = nextIndex;
|
|
183
|
+
hostnameRunStart = index;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (protectedTags.length > 0) {
|
|
187
|
+
index++;
|
|
188
|
+
hostnameRunStart = index;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const matchedProtocol = getProtocolAt(text, index);
|
|
192
|
+
if (matchedProtocol) {
|
|
193
|
+
const candidate = extendUrlCandidateBoundaries(text, index, index + matchedProtocol.length);
|
|
194
|
+
candidates.push(candidate);
|
|
195
|
+
index = candidate.end;
|
|
196
|
+
hostnameRunStart = candidate.end;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!isHostnameCharacter(text[index])) {
|
|
200
|
+
hostnameRunStart = index + 1;
|
|
201
|
+
index++;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (text[index] !== '.') {
|
|
205
|
+
index++;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const hostnameEnd = findHostnameEnd(text, hostnameRunStart, index);
|
|
209
|
+
if (hostnameEnd === undefined) {
|
|
210
|
+
index++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const candidate = extendUrlCandidateBoundaries(text, hostnameRunStart, hostnameEnd);
|
|
214
|
+
candidates.push(candidate);
|
|
215
|
+
index = candidate.end;
|
|
216
|
+
hostnameRunStart = candidate.end;
|
|
217
|
+
}
|
|
218
|
+
return candidates;
|
|
219
|
+
}
|
|
220
|
+
/** Finds possible bold or strikethrough pairs, preserves marker order inside protected tags, and runs the existing regex only on each candidate. */
|
|
221
|
+
function replaceMarkdownCandidates(text, regexp, replacement, marker, canOpen) {
|
|
222
|
+
if (!text.includes(marker)) {
|
|
223
|
+
return text;
|
|
224
|
+
}
|
|
225
|
+
const markers = [];
|
|
226
|
+
const protectedTags = [];
|
|
227
|
+
let index = 0;
|
|
228
|
+
while (index < text.length) {
|
|
229
|
+
if (text[index] === '<') {
|
|
230
|
+
const nextIndex = updateProtectedTagStack(text, index, protectedTags);
|
|
231
|
+
if (nextIndex === undefined) {
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
index = nextIndex;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Keep protected markers in order, but remember that they cannot be replaced.
|
|
238
|
+
if (text[index] === marker) {
|
|
239
|
+
markers.push({ position: index, isProtected: protectedTags.length > 0 });
|
|
240
|
+
}
|
|
241
|
+
index++;
|
|
242
|
+
}
|
|
243
|
+
// A Markdown range needs both an opening and closing marker, such as the two * characters in *bold*.
|
|
244
|
+
if (markers.length < 2) {
|
|
245
|
+
return text;
|
|
246
|
+
}
|
|
247
|
+
const output = [];
|
|
248
|
+
const candidateRegex = regexp;
|
|
249
|
+
let outputStart = 0;
|
|
250
|
+
let openingMarker;
|
|
251
|
+
for (const currentMarker of markers) {
|
|
252
|
+
const markerPosition = currentMarker.position;
|
|
253
|
+
if (openingMarker === undefined) {
|
|
254
|
+
openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : undefined;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (currentMarker.isProtected || !canCloseMarkdown(text, markerPosition, marker)) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (openingMarker.isProtected) {
|
|
261
|
+
openingMarker = undefined;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const openingPosition = openingMarker.position;
|
|
265
|
+
const prefixLength = openingPosition > 0 ? 1 : 0;
|
|
266
|
+
const suffixLength = markerPosition + 1 < text.length ? 1 : 0;
|
|
267
|
+
const candidateStart = openingPosition - prefixLength;
|
|
268
|
+
const candidateEnd = markerPosition + 1 + suffixLength;
|
|
269
|
+
const candidate = text.slice(candidateStart, candidateEnd);
|
|
270
|
+
candidateRegex.lastIndex = 0;
|
|
271
|
+
const candidateMatch = candidateRegex.exec(candidate);
|
|
272
|
+
if (!candidateMatch) {
|
|
273
|
+
openingMarker = canOpen(text, markerPosition, currentMarker.isProtected) ? currentMarker : undefined;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
candidateRegex.lastIndex = 0;
|
|
277
|
+
const replacedCandidate = replaceTextWithExtras(candidate, candidateRegex, EXTRAS_DEFAULT, replacement);
|
|
278
|
+
if (replacedCandidate !== candidate) {
|
|
279
|
+
const replacedCoreEnd = suffixLength ? replacedCandidate.length - suffixLength : replacedCandidate.length;
|
|
280
|
+
output.push(text.slice(outputStart, openingPosition));
|
|
281
|
+
output.push(replacedCandidate.slice(prefixLength, replacedCoreEnd));
|
|
282
|
+
outputStart = markerPosition + 1;
|
|
283
|
+
openingMarker = undefined;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
openingMarker = undefined;
|
|
287
|
+
}
|
|
288
|
+
if (output.length === 0) {
|
|
289
|
+
return text;
|
|
290
|
+
}
|
|
291
|
+
output.push(text.slice(outputStart));
|
|
292
|
+
return output.join('');
|
|
293
|
+
}
|
|
50
294
|
/**
|
|
51
295
|
* replace block element with '\n' if :
|
|
52
296
|
* 1. We have text within the element.
|
|
@@ -509,7 +753,7 @@ class ExpensiMark {
|
|
|
509
753
|
name: 'autolink',
|
|
510
754
|
process: (textToProcess, replacement) => {
|
|
511
755
|
const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gi');
|
|
512
|
-
return this.modifyTextForUrlLinks(regex, textToProcess, replacement);
|
|
756
|
+
return this.modifyTextForUrlLinks(regex, textToProcess, replacement, true);
|
|
513
757
|
},
|
|
514
758
|
replacement: (_extras, _match, g1, g2) => {
|
|
515
759
|
const href = Str.sanitizeURL(g2);
|
|
@@ -615,7 +859,7 @@ class ExpensiMark {
|
|
|
615
859
|
// \B will match everything that \b doesn't, so it works
|
|
616
860
|
// for * and ~: https://www.rexegg.com/regex-boundaries.html#notb
|
|
617
861
|
name: 'bold',
|
|
618
|
-
|
|
862
|
+
process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, BOLD_MARKDOWN_REGEX, replacement, '*', canOpenBoldMarkdown),
|
|
619
863
|
replacement: (_extras, match, g1, g2) => {
|
|
620
864
|
if (g1.includes('_')) {
|
|
621
865
|
return `${g1}<strong>${g2}</strong>`;
|
|
@@ -625,7 +869,7 @@ class ExpensiMark {
|
|
|
625
869
|
},
|
|
626
870
|
{
|
|
627
871
|
name: 'strikethrough',
|
|
628
|
-
|
|
872
|
+
process: (textToProcess, replacement) => replaceMarkdownCandidates(textToProcess, STRIKETHROUGH_MARKDOWN_REGEX, replacement, '~', canOpenStrikethroughMarkdown),
|
|
629
873
|
replacement: (_extras, match, g1) => (g1.includes('</pre>') || containsNonPairTag(g1) ? match : `<del>${g1}</del>`),
|
|
630
874
|
},
|
|
631
875
|
{
|
|
@@ -1073,7 +1317,25 @@ class ExpensiMark {
|
|
|
1073
1317
|
/**
|
|
1074
1318
|
* Checks matched URLs for validity and replace valid links with html elements
|
|
1075
1319
|
*/
|
|
1076
|
-
modifyTextForUrlLinks(regex, textToCheck, replacement) {
|
|
1320
|
+
modifyTextForUrlLinks(regex, textToCheck, replacement, shouldScanForUrls = false) {
|
|
1321
|
+
if (shouldScanForUrls) {
|
|
1322
|
+
const candidates = findUrlCandidates(textToCheck);
|
|
1323
|
+
if (candidates.length === 0) {
|
|
1324
|
+
return textToCheck;
|
|
1325
|
+
}
|
|
1326
|
+
const output = [];
|
|
1327
|
+
const candidateRegex = regex;
|
|
1328
|
+
let outputStart = 0;
|
|
1329
|
+
for (const { start, end } of candidates) {
|
|
1330
|
+
const candidate = textToCheck.slice(start, end);
|
|
1331
|
+
candidateRegex.lastIndex = 0;
|
|
1332
|
+
output.push(textToCheck.slice(outputStart, start));
|
|
1333
|
+
output.push(this.modifyTextForUrlLinks(candidateRegex, candidate, replacement));
|
|
1334
|
+
outputStart = end;
|
|
1335
|
+
}
|
|
1336
|
+
output.push(textToCheck.slice(outputStart));
|
|
1337
|
+
return output.join('');
|
|
1338
|
+
}
|
|
1077
1339
|
let match = regex.exec(textToCheck);
|
|
1078
1340
|
let replacedText = '';
|
|
1079
1341
|
let startIndex = 0;
|