expensify-common 2.0.188 → 2.0.190

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.
Files changed (78) hide show
  1. package/dist/Logger.js +11 -1
  2. package/dist/esm/API.d.ts +11 -0
  3. package/dist/esm/API.js +786 -0
  4. package/dist/esm/APIDeferred.d.ts +7 -0
  5. package/dist/esm/APIDeferred.js +216 -0
  6. package/dist/esm/BrowserDetect.d.ts +19 -0
  7. package/dist/esm/BrowserDetect.js +105 -0
  8. package/dist/esm/CLI.js +37 -29
  9. package/dist/esm/CONST.d.ts +1727 -0
  10. package/dist/esm/CONST.js +1847 -0
  11. package/dist/esm/Cookie.d.ts +68 -0
  12. package/dist/esm/Cookie.js +159 -0
  13. package/dist/esm/CredentialsWrapper.d.ts +32 -0
  14. package/dist/esm/CredentialsWrapper.js +46 -0
  15. package/dist/esm/Device.d.ts +8 -0
  16. package/dist/esm/Device.js +24 -0
  17. package/dist/esm/ExpenseRule.d.ts +39 -0
  18. package/dist/esm/ExpenseRule.js +77 -0
  19. package/dist/esm/ExpensiMark.d.ts +197 -0
  20. package/dist/esm/ExpensiMark.js +1374 -0
  21. package/dist/esm/Func.d.ts +40 -0
  22. package/dist/esm/Func.js +69 -0
  23. package/dist/esm/Log.d.ts +3 -0
  24. package/dist/esm/Log.js +35 -0
  25. package/dist/esm/Logger.d.ts +82 -0
  26. package/dist/esm/Logger.js +148 -0
  27. package/dist/esm/Network.d.ts +6 -0
  28. package/dist/esm/Network.js +168 -0
  29. package/dist/esm/Num.d.ts +95 -0
  30. package/dist/esm/Num.js +190 -0
  31. package/dist/esm/PageEvent.d.ts +25 -0
  32. package/dist/esm/PageEvent.js +22 -0
  33. package/dist/esm/PubSub.d.ts +2 -0
  34. package/dist/esm/PubSub.js +111 -0
  35. package/dist/esm/ReportHistoryStore.d.ts +8 -0
  36. package/dist/esm/ReportHistoryStore.js +199 -0
  37. package/dist/esm/SafeString.js +2 -2
  38. package/dist/esm/Templates.d.ts +58 -0
  39. package/dist/esm/Templates.js +196 -0
  40. package/dist/esm/Url.d.ts +10 -0
  41. package/dist/esm/Url.js +15 -0
  42. package/dist/esm/components/CopyText.d.ts +45 -0
  43. package/dist/esm/components/CopyText.js +56 -0
  44. package/dist/esm/components/StepProgressBar.d.ts +26 -0
  45. package/dist/esm/components/StepProgressBar.js +40 -0
  46. package/dist/esm/components/form/element/combobox.d.ts +231 -0
  47. package/dist/esm/components/form/element/combobox.js +812 -0
  48. package/dist/esm/components/form/element/dropdown.d.ts +35 -0
  49. package/dist/esm/components/form/element/dropdown.js +61 -0
  50. package/dist/esm/components/form/element/dropdownItem.d.ts +55 -0
  51. package/dist/esm/components/form/element/dropdownItem.js +113 -0
  52. package/dist/esm/components/form/element/onOffSwitch.d.ts +94 -0
  53. package/dist/esm/components/form/element/onOffSwitch.js +167 -0
  54. package/dist/esm/components/form/element/switch.d.ts +58 -0
  55. package/dist/esm/components/form/element/switch.js +98 -0
  56. package/dist/esm/fastMerge.d.ts +9 -0
  57. package/dist/esm/fastMerge.js +58 -0
  58. package/dist/esm/index.d.ts +22 -0
  59. package/dist/esm/index.js +22 -0
  60. package/dist/esm/jquery.expensifyIframify.d.ts +9 -0
  61. package/dist/esm/jquery.expensifyIframify.js +397 -0
  62. package/dist/esm/md5.d.ts +2 -0
  63. package/dist/esm/md5.js +170 -0
  64. package/dist/esm/mixins/PubSub.d.ts +20 -0
  65. package/dist/esm/mixins/PubSub.js +47 -0
  66. package/dist/esm/mixins/extraClasses.d.ts +8 -0
  67. package/dist/esm/mixins/extraClasses.js +37 -0
  68. package/dist/esm/mixins/validationClasses.d.ts +12 -0
  69. package/dist/esm/mixins/validationClasses.js +30 -0
  70. package/dist/esm/str.d.ts +604 -0
  71. package/dist/esm/str.js +1036 -0
  72. package/dist/esm/tlds.d.ts +2 -0
  73. package/dist/esm/tlds.js +2 -0
  74. package/dist/esm/utils.d.ts +30 -0
  75. package/dist/esm/utils.js +65 -0
  76. package/dist/index.d.ts +1 -0
  77. package/dist/index.js +8 -1
  78. package/package.json +221 -4
@@ -0,0 +1,1374 @@
1
+ 'worklet';
2
+ import Str from './str';
3
+ import * as Constants from './CONST';
4
+ import * as UrlPatterns from './Url';
5
+ import Logger from './Logger';
6
+ import * as Utils from './utils';
7
+ const EXTRAS_DEFAULT = {};
8
+ const MARKDOWN_LINK_REGEX = new RegExp(`\\[((?:[^\\[\\]\\r\\n]*(?:\\[[^\\[\\]\\r\\n]*][^\\[\\]\\r\\n]*)*))]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
9
+ const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
10
+ const MARKDOWN_VIDEO_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(((${UrlPatterns.MARKDOWN_URL_REGEX})\\.(?:${Constants.CONST.VIDEO_EXTENSIONS.join('|')}))\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
11
+ 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
+ // Preserve VirtualCFO chart blocks by matching the outer <VictoryChart> container.
13
+ // This captures all nested Victory components and prevents markup escaping during conversion.
14
+ const VICTORY_CHART_REGEX = /<VictoryChart\b[^>]*\/>|<VictoryChart\b[^>]*>[\s\S]*?<\/VictoryChart>/gi;
15
+ // Chart placeholders use NUL characters as delimiters to prevent conflicts with user content.
16
+ const VICTORY_CHART_PLACEHOLDER_DELIMITER = String.fromCharCode(0);
17
+ const VICTORY_CHART_PLACEHOLDER_PATTERN = new RegExp(`${VICTORY_CHART_PLACEHOLDER_DELIMITER}(\\d+)${VICTORY_CHART_PLACEHOLDER_DELIMITER}`, 'g');
18
+ const createVictoryChartPlaceholder = (index) => `${VICTORY_CHART_PLACEHOLDER_DELIMITER}${index}${VICTORY_CHART_PLACEHOLDER_DELIMITER}`;
19
+ function extractVictoryChartTags(text) {
20
+ const tags = [];
21
+ const out = text.replaceAll(VICTORY_CHART_REGEX, (match) => {
22
+ const placeholder = createVictoryChartPlaceholder(tags.length);
23
+ tags.push(match);
24
+ return placeholder;
25
+ });
26
+ return { text: out, tags };
27
+ }
28
+ function restoreVictoryChartTags(text, tags) {
29
+ if (tags.length === 0) {
30
+ return text;
31
+ }
32
+ return text.replaceAll(VICTORY_CHART_PLACEHOLDER_PATTERN, (match, idx) => { var _a; return (_a = tags[Number(idx)]) !== null && _a !== void 0 ? _a : match; });
33
+ }
34
+ function resolveAttributeCache(extras) {
35
+ var _a, _b;
36
+ if (!extras) {
37
+ return { attrCachingFn: undefined, attrCache: undefined };
38
+ }
39
+ return {
40
+ attrCachingFn: (_a = extras.mediaAttributeCachingFn) !== null && _a !== void 0 ? _a : extras.cacheVideoAttributes,
41
+ attrCache: (_b = extras.mediaAttributeCache) !== null && _b !== void 0 ? _b : extras.videoAttributeCache,
42
+ };
43
+ }
44
+ function replaceTextWithExtras(text, regexp, extras, replacement) {
45
+ if (typeof replacement === 'function') {
46
+ return text.replace(regexp, (...args) => replacement(extras, ...args));
47
+ }
48
+ return text.replace(regexp, replacement);
49
+ }
50
+ /**
51
+ * replace block element with '\n' if :
52
+ * 1. We have text within the element.
53
+ * 2. The text does not end with a new line.
54
+ * 3. It's not the last element in the string.
55
+ */
56
+ function replaceBlockElementWithNewLine(htmlString) {
57
+ let splitText = htmlString
58
+ // Lines starting with quote mark '>' will have '\n' added to them so to avoid adding extra '\n', remove the block element right next to it
59
+ .replaceAll(/<blockquote>> (<div.*?>|<\/div>|<comment.*?>|\n<\/comment>|<\/comment>|<h1>|<\/h1>|<h2>|<\/h2>|<h3>|<\/h3>|<h4>|<\/h4>|<h5>|<\/h5>|<h6>|<\/h6>|<p>|<\/p>|<li>|<\/li>)/gi, '<blockquote>> ')
60
+ .split(/<div.*?>|<\/div>|<comment.*?>|\n<\/comment>|<\/comment>|<h1>|<\/h1>|<h2>|<\/h2>|<h3>|<\/h3>|<h4>|<\/h4>|<h5>|<\/h5>|<h6>|<\/h6>|<p>|<\/p>|<li>|<\/li>|<blockquote>|<\/blockquote>/);
61
+ const stripHTML = (text) => Str.stripHTML(text);
62
+ splitText = splitText.map(stripHTML);
63
+ let joinedText = '';
64
+ // Delete whitespace at the end
65
+ while (splitText.length) {
66
+ if (splitText[splitText.length - 1].trim().length > 0 || splitText[splitText.length - 1].match(/\n/)) {
67
+ break;
68
+ }
69
+ splitText.pop();
70
+ }
71
+ const processText = (text, index) => {
72
+ if (text.trim().length === 0 && !text.match(/\n/)) {
73
+ return;
74
+ }
75
+ // Insert '\n' unless it ends with '\n' or it's the last element, or if it's a header ('# ') with a space.
76
+ if (text.match(/\n[\s]?$/) || index === splitText.length - 1 || text === '# ') {
77
+ joinedText += text;
78
+ }
79
+ else {
80
+ joinedText += `${text}\n`;
81
+ }
82
+ };
83
+ for (const [index, text] of splitText.entries()) {
84
+ processText(text, index);
85
+ }
86
+ return joinedText;
87
+ }
88
+ /** Check if the input text includes only the open or the close tag of an element. */
89
+ function containsNonPairTag(textToCheck) {
90
+ // Create a regular expression to match HTML tags
91
+ const tagRegExp = /<([a-z][a-z0-9-]*)\b[^>]*>|<\/([a-z][a-z0-9-]*)\s*>/gi;
92
+ // Use a stack to keep track of opening tags
93
+ const tagStack = [];
94
+ // Match all HTML tags in the string
95
+ let match = tagRegExp.exec(textToCheck);
96
+ while (match) {
97
+ const openingTag = match[1];
98
+ const closingTag = match[2];
99
+ if (openingTag && openingTag !== 'br') {
100
+ // If it's an opening tag, push it onto the stack
101
+ tagStack.push(openingTag);
102
+ }
103
+ else if (closingTag) {
104
+ // If it's a closing tag, pop the top of the stack
105
+ const expectedTag = tagStack.pop();
106
+ // If the closing tag doesn't match the expected opening tag, return false
107
+ if (closingTag !== expectedTag) {
108
+ return true;
109
+ }
110
+ }
111
+ match = tagRegExp.exec(textToCheck);
112
+ }
113
+ // If there are any tags left in the stack, they're unclosed
114
+ return tagStack.length !== 0;
115
+ }
116
+ /**
117
+ * Determines the end position to truncate the HTML content while considering word boundaries.
118
+ */
119
+ function getEndPosition(content, tailPosition, maxLength, totalLength, opts) {
120
+ const WORD_BREAK_REGEX = /\W+/g;
121
+ const defaultPosition = maxLength - totalLength;
122
+ const slop = opts.slop;
123
+ if (!slop) {
124
+ return defaultPosition;
125
+ }
126
+ let position = defaultPosition;
127
+ const isShort = defaultPosition < slop;
128
+ const slopPos = isShort ? defaultPosition : slop - 1;
129
+ const substr = content.slice(isShort ? 0 : defaultPosition - slop, tailPosition !== undefined ? tailPosition : defaultPosition + slop);
130
+ const wordBreakMatch = WORD_BREAK_REGEX.exec(substr);
131
+ if (!opts.truncateLastWord) {
132
+ if (tailPosition && substr.length <= tailPosition) {
133
+ position = substr.length;
134
+ }
135
+ else {
136
+ while (wordBreakMatch !== null) {
137
+ if (wordBreakMatch.index < slopPos) {
138
+ position = defaultPosition - (slopPos - wordBreakMatch.index);
139
+ if (wordBreakMatch.index === 0 && defaultPosition <= 1) {
140
+ break;
141
+ }
142
+ }
143
+ else if (wordBreakMatch.index === slopPos) {
144
+ position = defaultPosition;
145
+ break;
146
+ }
147
+ else {
148
+ position = defaultPosition + (wordBreakMatch.index - slopPos);
149
+ break;
150
+ }
151
+ }
152
+ }
153
+ if (content.charAt(position - 1).match(/\s$/)) {
154
+ position--;
155
+ }
156
+ }
157
+ return position;
158
+ }
159
+ /**
160
+ * Truncate HTML string and keep tag safe.
161
+ * pulled from https://github.com/huang47/nodejs-html-truncate/blob/master/lib/truncate.js
162
+ */
163
+ function truncateHTML(html, maxLength, options) {
164
+ const EMPTY_STRING = '';
165
+ const DEFAULT_TRUNCATE_SYMBOL = '...';
166
+ const DEFAULT_SLOP = Math.min(10, maxLength);
167
+ const tagsStack = [];
168
+ const KEY_VALUE_REGEX = '((?:\\s+(?:\\w+|-)+(?:\\s*=\\s*(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\'">\\s]+))?)*)';
169
+ const IS_CLOSE_REGEX = '\\s*\\/?\\s*';
170
+ const CLOSE_REGEX = '\\s*\\/\\s*';
171
+ const SELF_CLOSE_REGEX = new RegExp(`<\\/?(\\w+)${KEY_VALUE_REGEX}${CLOSE_REGEX}>`);
172
+ const HTML_TAG_REGEX = new RegExp(`<\\/?(\\w+)${KEY_VALUE_REGEX}${IS_CLOSE_REGEX}>`);
173
+ const URL_REGEX = /(((ftp|https?):\/\/)[\\-\w@:%_\\+.~#?,&\\/\\/=]+)|((mailto:)?[_.\w\\-]+@([\w][\w\\-]+\.)+[a-zA-Z]{2,3})/g;
174
+ const IMAGE_TAG_REGEX = new RegExp(`<img\\s*${KEY_VALUE_REGEX}${CLOSE_REGEX}>`);
175
+ let truncatedContent = EMPTY_STRING;
176
+ let totalLength = 0;
177
+ let matches = HTML_TAG_REGEX.exec(html);
178
+ let endResult;
179
+ let index;
180
+ let tag;
181
+ let selfClose = null;
182
+ let htmlString = html;
183
+ const opts = Object.assign({ ellipsis: DEFAULT_TRUNCATE_SYMBOL, truncateLastWord: true, slop: DEFAULT_SLOP }, options);
184
+ function removeImageTag(content) {
185
+ const match = IMAGE_TAG_REGEX.exec(content);
186
+ if (!match) {
187
+ return content;
188
+ }
189
+ const matchIndex = match.index;
190
+ const matchLength = match[0].length;
191
+ return content.substring(0, matchIndex) + content.substring(matchIndex + matchLength);
192
+ }
193
+ function closeTags(tags) {
194
+ return tags
195
+ .reverse()
196
+ .map((mappedTag) => `</${mappedTag}>`)
197
+ .join('');
198
+ }
199
+ while (matches) {
200
+ matches = HTML_TAG_REGEX.exec(htmlString);
201
+ if (!matches) {
202
+ if (totalLength >= maxLength) {
203
+ break;
204
+ }
205
+ matches = URL_REGEX.exec(htmlString);
206
+ if (!matches || matches.index >= maxLength) {
207
+ truncatedContent += htmlString.substring(0, getEndPosition(htmlString, undefined, maxLength, totalLength, opts));
208
+ break;
209
+ }
210
+ while (matches) {
211
+ endResult = matches[0];
212
+ if (endResult !== null) {
213
+ index = matches.index;
214
+ const truncateEnd = index + endResult.length;
215
+ truncatedContent += htmlString.substring(0, truncateEnd - totalLength);
216
+ htmlString = htmlString.substring(index + endResult.length);
217
+ matches = URL_REGEX.exec(htmlString);
218
+ }
219
+ }
220
+ break;
221
+ }
222
+ endResult = matches[0];
223
+ index = matches.index;
224
+ if (totalLength + index > maxLength) {
225
+ truncatedContent += htmlString.substring(0, getEndPosition(htmlString, index, maxLength, totalLength, opts));
226
+ break;
227
+ }
228
+ else {
229
+ totalLength += index;
230
+ truncatedContent += htmlString.substring(0, index);
231
+ }
232
+ if (endResult[1] === '/') {
233
+ tagsStack.pop();
234
+ selfClose = null;
235
+ }
236
+ else {
237
+ selfClose = SELF_CLOSE_REGEX.exec(endResult);
238
+ if (!selfClose) {
239
+ tag = matches[1];
240
+ tagsStack.push(tag);
241
+ }
242
+ }
243
+ truncatedContent += selfClose ? selfClose[0] : endResult;
244
+ htmlString = htmlString.substring(index + endResult.length);
245
+ }
246
+ if (htmlString.length > maxLength - totalLength && opts.ellipsis) {
247
+ truncatedContent += opts.ellipsis ? '...' : '';
248
+ }
249
+ truncatedContent += closeTags(tagsStack);
250
+ if (opts.removeImageTag) {
251
+ truncatedContent = removeImageTag(truncatedContent);
252
+ }
253
+ return truncatedContent;
254
+ }
255
+ class ExpensiMark {
256
+ /**
257
+ * Set the logger to use for logging inside of the ExpensiMark class
258
+ * @param logger - The logger object to use
259
+ */
260
+ static setLogger(logger) {
261
+ ExpensiMark.Log = logger;
262
+ }
263
+ constructor() {
264
+ /**
265
+ * The list of rules that we have to exclude in shouldKeepWhitespaceRules list.
266
+ */
267
+ this.whitespaceRulesToDisable = ['newline', 'replacepre', 'replacebr', 'replaceh1br'];
268
+ this.getAttributeCache = resolveAttributeCache;
269
+ this.replaceBlockElementWithNewLine = replaceBlockElementWithNewLine;
270
+ this.containsNonPairTag = containsNonPairTag;
271
+ this.truncateHTML = truncateHTML;
272
+ /**
273
+ * The list of regex replacements to do on a comment. Check the link regex is first so links are processed
274
+ * before other delimiters
275
+ */
276
+ this.rules = [
277
+ // Apply the emoji first avoid applying any other formatting rules inside of it
278
+ {
279
+ name: 'emoji',
280
+ regex: Constants.CONST.REG_EXP.EMOJI_RULE,
281
+ replacement: (_extras, match) => `<emoji>${match}</emoji>`,
282
+ },
283
+ /**
284
+ * Apply the code-fence to avoid replacing anything inside of it that we're not supposed to
285
+ * (aka any rule with the '(?![^<]*<\/pre>)' avoidance in it
286
+ */
287
+ {
288
+ name: 'codeFence',
289
+ // &#x60; is a backtick symbol we are matching on three of them before then after a new line character
290
+ regex: /(?<![^^\r\n])((?:&#x60;&#x60;&#x60;|```)(\r\n|\n))((?:\s*?(?!(?:\r\n|\n)?(?:&#x60;&#x60;&#x60;|```)(?!&#x60;|`))[\S])+\s*?(?:\r\n|\n))(&#x60;&#x60;&#x60;|```)/g,
291
+ // We're using a function here to perform an additional replace on the content
292
+ // inside the backticks because Android is not able to use <pre> tags and does
293
+ // not respect whitespace characters at all like HTML does. We do not want to mess
294
+ // with the new lines here since they need to be converted into <br>. And we don't
295
+ // want to do this anywhere else since that would break HTML.
296
+ // &nbsp; will create styling issues so use &#32;
297
+ replacement: (_extras, _match, _g1, _g2, textWithinFences) => {
298
+ const group = textWithinFences.replaceAll(/(?:(?![\n\r])\s)/g, '&#32;');
299
+ return `<pre>${group}</pre>`;
300
+ },
301
+ rawInputReplacement: (_extras, _match, _g1, newLineCharacter, textWithinFences) => {
302
+ const group = textWithinFences.replaceAll(/(?:(?![\n\r])\s)/g, '&#32;').replaceAll(/<emoji>|<\/emoji>/g, '');
303
+ return `<pre>${newLineCharacter}${group}</pre>`;
304
+ },
305
+ },
306
+ /**
307
+ * Converts markdown style video to video tags e.g. ![Expensify](https://www.expensify.com/attachment.mp4)
308
+ * We need to convert before image rules since they will not try to create a image tag from an existing video URL
309
+ * Extras arg could contain the attribute cache for the video tag which is cached during the html-to-markdown conversion
310
+ */
311
+ {
312
+ name: 'video',
313
+ regex: MARKDOWN_VIDEO_REGEX,
314
+ /**
315
+ * @param extras - The extras object
316
+ * @param videoName - The first capture group - video name
317
+ * @param videoSource - The second capture group - video URL
318
+ * @return Returns the HTML video tag
319
+ */
320
+ replacement: (extras, _match, videoName, videoSource) => {
321
+ const attrCache = resolveAttributeCache(extras).attrCache;
322
+ const extraAttrs = attrCache && attrCache[videoSource];
323
+ return `<video data-expensify-source="${Str.sanitizeURL(videoSource)}" ${extraAttrs || ''}>${videoName ? `${videoName}` : ''}</video>`;
324
+ },
325
+ rawInputReplacement: (extras, _match, videoName, videoSource) => {
326
+ const attrCache = resolveAttributeCache(extras).attrCache;
327
+ const extraAttrs = attrCache && attrCache[videoSource];
328
+ return `<video data-expensify-source="${Str.sanitizeURL(videoSource)}" data-raw-href="${videoSource}" data-link-variant="${typeof videoName === 'string' ? 'labeled' : 'auto'}" ${extraAttrs || ''}>${videoName ? `${videoName}` : ''}</video>`;
329
+ },
330
+ shouldSkipProcessing: (textToCheck) => {
331
+ const missingSpecialCharacters = !textToCheck.includes('!') || !textToCheck.includes('(') || !textToCheck.includes(')');
332
+ const missingVideoExtension = !Constants.CONST.VIDEO_EXTENSIONS.some((extension) => textToCheck.toLowerCase().includes(`.${extension.toLowerCase()}`));
333
+ return missingSpecialCharacters || missingVideoExtension;
334
+ },
335
+ },
336
+ /**
337
+ * Apply inline code-block to avoid applying any other formatting rules inside of it,
338
+ * like we do for the multi-line code-blocks
339
+ */
340
+ {
341
+ name: 'inlineCodeBlock',
342
+ // Use the url escaped version of a backtick (`) symbol. Mobile platforms do not support lookbehinds,
343
+ // so capture the first and third group and place them in the replacement.
344
+ // but we should not replace backtick symbols if they include <pre> tags between them.
345
+ // At least one non-whitespace character or a specific whitespace character (" " and "\u00A0")
346
+ // must be present inside the backticks.
347
+ regex: /(\B|_|)(?:&#x60;|`)(.*?)(?:&#x60;|`)(\B|_|)(?!(?!<pre>)[^<]*(?:<(?!pre>)[^<]*)*<\/pre>|[^<]*<\/video>)/gm,
348
+ replacement: (_extras, match, g1, g2, g3) => {
349
+ const g2Value = g2.trim() === '' ? g2.replaceAll(' ', '&nbsp;') : g2;
350
+ if (!g2Value) {
351
+ return match;
352
+ }
353
+ return `${g1}<code>${g2Value}</code>${g3}`;
354
+ },
355
+ rawInputReplacement: (_extras, match, g1, g2, g3) => (g2 ? `${g1}<code>${g2}</code>${g3}` : match),
356
+ },
357
+ /**
358
+ * Converts markdown style links to anchor tags e.g. [Expensify](concierge@expensify.com)
359
+ * We need to convert before the auto email link rule and the manual link rule since it will not try to
360
+ * create a link from an existing anchor tag.
361
+ */
362
+ {
363
+ name: 'email',
364
+ process: (textToProcess, replacement, shouldKeepRawInput) => {
365
+ const regex = new RegExp(`(?!\\[\\s*\\])\\[([^[\\]]*)]\\((mailto:)?${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}\\)`, 'gim');
366
+ return this.modifyTextForEmailLinks(regex, textToProcess, replacement, shouldKeepRawInput);
367
+ },
368
+ replacement: (_extras, match, g1, g2) => {
369
+ if (!g1.trim()) {
370
+ return match;
371
+ }
372
+ const label = g1.trim();
373
+ const href = `mailto:${g2}`;
374
+ const formattedLabel = label === href ? g2 : label;
375
+ return `<a href="${href}">${formattedLabel}</a>`;
376
+ },
377
+ rawInputReplacement: (_extras, match, g1, g2, g3) => {
378
+ if (!g1.trim()) {
379
+ return match;
380
+ }
381
+ const dataRawHref = g2 ? g2 + g3 : g3;
382
+ const href = `mailto:${g3}`;
383
+ return `<a href="${href}" data-raw-href="${dataRawHref}" data-link-variant="labeled">${g1}</a>`;
384
+ },
385
+ },
386
+ {
387
+ name: 'heading1',
388
+ process: (textToProcess, replacement, shouldKeepRawInput = false) => {
389
+ const regexp = shouldKeepRawInput ? /^# ( *(?! )(?:(?!<pre>|<video>|\n|\r\n).)+)/gm : /^# +(?! )((?:(?!<pre>|<video>|\n|\r\n).)+)/gm;
390
+ return replaceTextWithExtras(textToProcess, regexp, EXTRAS_DEFAULT, replacement);
391
+ },
392
+ replacement: '<h1>$1</h1>',
393
+ },
394
+ /**
395
+ * Converts markdown style images to image tags e.g. ![Expensify](https://www.expensify.com/attachment.png)
396
+ * We need to convert before linking rules since they will not try to create a link from an existing img
397
+ * tag.
398
+ * Additional sanitization is done to the alt attribute to prevent parsing it further to html by later
399
+ * rules.
400
+ * Additional tags from cache are applied to the result HTML.
401
+ */
402
+ {
403
+ name: 'image',
404
+ regex: MARKDOWN_IMAGE_REGEX,
405
+ replacement: (extras, _match, imgAlt, imgSource) => {
406
+ const attrCache = resolveAttributeCache(extras).attrCache;
407
+ const extraAttrs = attrCache && attrCache[imgSource];
408
+ return `<img src="${Str.sanitizeURL(imgSource)}"${imgAlt ? ` alt="${this.escapeAttributeContent(imgAlt)}"` : ''} ${extraAttrs || ''}/>`;
409
+ },
410
+ rawInputReplacement: (extras, _match, imgAlt, imgSource) => {
411
+ const attrCache = resolveAttributeCache(extras).attrCache;
412
+ const extraAttrs = attrCache && attrCache[imgSource];
413
+ return `<img src="${Str.sanitizeURL(imgSource)}"${imgAlt ? ` alt="${this.escapeAttributeContent(imgAlt)}"` : ''} data-raw-href="${imgSource}" data-link-variant="${typeof imgAlt === 'string' ? 'labeled' : 'auto'}" ${extraAttrs || ''}/>`;
414
+ },
415
+ shouldSkipProcessing: (textToCheck) => !textToCheck.includes('!') || !textToCheck.includes('(') || !textToCheck.includes(')'),
416
+ },
417
+ /**
418
+ * Converts markdown style links to anchor tags e.g. [Expensify](https://www.expensify.com)
419
+ * We need to convert before the autolink rule since it will not try to create a link
420
+ * from an existing anchor tag.
421
+ */
422
+ {
423
+ name: 'link',
424
+ process: (textToProcess, replacement) => this.modifyTextForUrlLinks(MARKDOWN_LINK_REGEX, textToProcess, replacement),
425
+ replacement: (_extras, match, g1, g2) => {
426
+ if (!g1.trim()) {
427
+ return match;
428
+ }
429
+ return `<a href="${Str.sanitizeURL(g2)}" target="_blank" rel="noreferrer noopener">${g1.trim()}</a>`;
430
+ },
431
+ rawInputReplacement: (_extras, match, g1, g2) => {
432
+ if (!g1.trim()) {
433
+ return match;
434
+ }
435
+ return `<a href="${Str.sanitizeURL(g2)}" data-raw-href="${g2}" data-link-variant="labeled" target="_blank" rel="noreferrer noopener">${g1}</a>`;
436
+ },
437
+ shouldSkipProcessing: (textToCheck) => !textToCheck.includes('.'),
438
+ },
439
+ /**
440
+ * Apply the hereMention first because the string @here is still a valid mention for the userMention regex.
441
+ * This ensures that the hereMention is always considered first, even if it is followed by a valid
442
+ * userMention.
443
+ *
444
+ * Also, apply the mention rule after email/link to prevent mention appears in an email/link.
445
+ */
446
+ {
447
+ name: 'hereMentions',
448
+ regex: /([a-zA-Z0-9.!$%&+/=?^`{|}_-]?)(@here)([.!$%&+/=?^`{|}_-]?)(?=\b)(?!([\w'#%+-]*@(?:[a-z\d-]+\.)+[a-z]{2,}(?:\s|$|@here))|((?:(?!<a).)+)?<\/a>|[^<]*(<\/pre>|<\/code>))/gm,
449
+ replacement: (_extras, match, g1, g2, g3) => {
450
+ if (!Str.isValidMention(match)) {
451
+ return match;
452
+ }
453
+ return `${g1}<mention-here>${g2}</mention-here>${g3}`;
454
+ },
455
+ },
456
+ /**
457
+ * A room mention is a string that starts with the '#' symbol and is followed by a valid room name.
458
+ *
459
+ * Note: We are allowing mentions in a format of #room-name The room name can contain any
460
+ * combination of letters and hyphens
461
+ */
462
+ {
463
+ name: 'reportMentions',
464
+ regex: /(?<=^[*~_:]?|[ \n][*~_:]?|[:#])(#[\p{Ll}0-9-]{1,99})(?![^<]*(?:<\/pre>|<\/code>|<\/a>))/gimu,
465
+ replacement: '<mention-report>$1</mention-report>',
466
+ },
467
+ /**
468
+ * This regex matches a valid user mention in a string.
469
+ * A user mention is a string that starts with the '@' symbol and is followed by a valid user's primary
470
+ * login
471
+ *
472
+ * Note: currently we are only allowing mentions in a format of @+19728974297 (E.164 format phone number)
473
+ * and @username@example.com The username can contain any combination of alphanumeric letters, numbers, and
474
+ * underscores
475
+ */
476
+ {
477
+ name: 'userMentions',
478
+ regex: new RegExp(`${Constants.CONST.REG_EXP.PRE_MENTION_TEXT_PART}(@${Constants.CONST.REG_EXP.EMAIL_PART}|@${Constants.CONST.REG_EXP.PHONE_PART})(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gim'),
479
+ replacement: (_extras, match, g1, g2) => {
480
+ const phoneNumberRegex = new RegExp(`^${Constants.CONST.REG_EXP.PHONE_PART}$`);
481
+ const mention = g2.slice(1);
482
+ const mentionWithoutSMSDomain = Str.removeSMSDomain(mention);
483
+ if (!Str.isValidMention(match) || (phoneNumberRegex.test(mentionWithoutSMSDomain) && !Str.isValidPhoneNumber(mentionWithoutSMSDomain))) {
484
+ return match;
485
+ }
486
+ const phoneRegex = new RegExp(`^@${Constants.CONST.REG_EXP.PHONE_PART}$`);
487
+ return `${g1}<mention-user>${g2}${phoneRegex.test(g2) ? `@${Constants.CONST.SMS.DOMAIN}` : ''}</mention-user>`;
488
+ },
489
+ rawInputReplacement: (_extras, match, g1, g2) => {
490
+ const phoneNumberRegex = new RegExp(`^${Constants.CONST.REG_EXP.PHONE_PART}$`);
491
+ const mention = g2.slice(1);
492
+ const mentionWithoutSMSDomain = Str.removeSMSDomain(mention);
493
+ if (!Str.isValidMention(match) || (phoneNumberRegex.test(mentionWithoutSMSDomain) && !Str.isValidPhoneNumber(mentionWithoutSMSDomain))) {
494
+ return match;
495
+ }
496
+ return `${g1}<mention-user>${g2}</mention-user>`;
497
+ },
498
+ },
499
+ {
500
+ name: 'hereMentionAfterUserMentions',
501
+ regex: /(<\/mention-user>)(@here)(?=\b)/gm,
502
+ replacement: '$1<mention-here>$2</mention-here>',
503
+ },
504
+ /**
505
+ * Automatically link urls. Runs last of our linkers since we want anything manual to link before this,
506
+ * and we do not want to break emails.
507
+ */
508
+ {
509
+ name: 'autolink',
510
+ process: (textToProcess, replacement) => {
511
+ const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gi');
512
+ return this.modifyTextForUrlLinks(regex, textToProcess, replacement);
513
+ },
514
+ replacement: (_extras, _match, g1, g2) => {
515
+ const href = Str.sanitizeURL(g2);
516
+ return `${g1}<a href="${href}" target="_blank" rel="noreferrer noopener">${g2}</a>${g1}`;
517
+ },
518
+ rawInputReplacement: (_extras, _match, g1, g2) => {
519
+ const href = Str.sanitizeURL(g2);
520
+ return `${g1}<a href="${href}" data-raw-href="${g2}" data-link-variant="auto" target="_blank" rel="noreferrer noopener">${g2}</a>${g1}`;
521
+ },
522
+ shouldSkipProcessing: (textToCheck) => !textToCheck.includes('.'),
523
+ },
524
+ {
525
+ name: 'quote',
526
+ // We also want to capture a blank line before or after the quote so that we do not add extra spaces.
527
+ // block quotes naturally appear on their own line. Blockquotes should not appear in code fences or
528
+ // inline code blocks. A single prepending space should be stripped if it exists
529
+ process: (textToProcess, replacement, shouldKeepRawInput = false) => {
530
+ const regex = /^(?:&gt;|>)+ +(?! )(?![^<]*(?:<\/pre>|<\/code>|<\/video>))([^\v\n\r]*)/gm;
531
+ let replacedText = replaceTextWithExtras(textToProcess, regex, EXTRAS_DEFAULT, replacement);
532
+ if (shouldKeepRawInput) {
533
+ return replacedText;
534
+ }
535
+ for (let i = this.maxQuoteDepth; i > 0; i--) {
536
+ replacedText = replacedText.replaceAll(`${'</blockquote>'.repeat(i)}\n${'<blockquote>'.repeat(i)}`, '\n');
537
+ }
538
+ replacedText = replacedText.replaceAll('</blockquote>\n', '</blockquote>');
539
+ return replacedText;
540
+ },
541
+ replacement: (_extras, g1) => {
542
+ const { replacedText } = this.replaceQuoteText(g1, false);
543
+ return `<blockquote>${replacedText || ' '}</blockquote>`;
544
+ },
545
+ rawInputReplacement: (_extras, g1) => {
546
+ const { replacedText, shouldAddSpace } = this.replaceQuoteText(g1, true);
547
+ return `<blockquote>${shouldAddSpace ? ' ' : ''}${replacedText}</blockquote>`;
548
+ },
549
+ },
550
+ /**
551
+ * Use \b in this case because it will match on words, letters,
552
+ * and _: https://www.rexegg.com/regex-boundaries.html#wordboundary
553
+ * Use [\s\S]* instead of .* to match newline
554
+ */
555
+ {
556
+ name: 'italic',
557
+ regex: /(<(pre|code|a|mention-user|video)[^>]*>(.*?)<\/\2>)|((\b_+|\b|(?<=_)(?<!\b[^\W_]*_))_((?![\s_])[\s\S]*?[^\s_](?<!\s))_(?![^\W_])(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/mention-user>|<\/video>)))/g,
558
+ replacement: (_extras, match, html, tag, content, text, extraLeadingUnderscores, textWithinUnderscores) => {
559
+ // Skip any <pre>, <code>, <a>, <mention-user>, <video> tag contents
560
+ if (html) {
561
+ return html;
562
+ }
563
+ // If any tags are included inside underscores, ignore it. ie. _abc <pre>pre tag</pre> abc_
564
+ if (textWithinUnderscores.includes('</pre>') || containsNonPairTag(textWithinUnderscores)) {
565
+ return match;
566
+ }
567
+ if (String(textWithinUnderscores).match(`^${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}`)) {
568
+ return `<em>${extraLeadingUnderscores}${textWithinUnderscores}</em>`;
569
+ }
570
+ return `${extraLeadingUnderscores}<em>${textWithinUnderscores}</em>`;
571
+ },
572
+ },
573
+ /**
574
+ * Automatically links emails that are not in a link. Runs before the autolinker as it will not link an
575
+ * email that is in a link
576
+ * Prevent emails from starting with [~_*]. Such emails should not be supported.
577
+ */
578
+ {
579
+ name: 'autoEmail',
580
+ regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!<a).)+)?<\\/a>|[^<>]*<\\/(?!em|h1|blockquote))`, 'gim'),
581
+ replacement: '$1<a href="mailto:$2">$2</a>',
582
+ rawInputReplacement: '$1<a href="mailto:$2" data-raw-href="$2" data-link-variant="auto">$2</a>',
583
+ },
584
+ /**
585
+ * This regex matches a short user mention in a string.
586
+ * A short-mention is a string that starts with the '@' symbol and is followed by a valid user's primary login without the email domain part
587
+ * Ex: @john.doe, @user12345, but NOT @user@email.com
588
+ *
589
+ * Notes:
590
+ * Phone is not a valid short mention.
591
+ * In reality these "short-mentions" are just possible candidates, because the parser has no way of verifying if there exists a user named ex: @john.examplename.
592
+ * The actual verification whether these mentions are pointing to real users is done in specific projects using ExpensiMark.
593
+ * Nevertheless, "@john.examplename" is a correct possible short-mention, and so would be parsed.
594
+ * This behaviour is similar to treating every user@something as valid user login.
595
+ *
596
+ * This regex will correctly preserve any @here mentions, the same way as "userMention" rule.
597
+ */
598
+ {
599
+ name: 'shortMentions',
600
+ regex: new RegExp(`${Constants.CONST.REG_EXP.PRE_MENTION_TEXT_PART}(@(?=((?=[\\w]+[\\w'#%+-]*(?:\\.[\\w'#%+-]+)*)[\\w\\.'#%+-]{1,64}(?= |_|\\b))(?!([:\\/\\\\]))(?<end>.*))(?!here)\\S{1,254}(?=\\k<end>$))(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>|<\\/mention-user>|<\\/mention-here>))`, 'gim'),
601
+ replacement: (_extras, match, g1, g2) => {
602
+ if (!Str.isValidMention(match)) {
603
+ return match;
604
+ }
605
+ return `${g1}<mention-short>${g2}</mention-short>`;
606
+ },
607
+ },
608
+ {
609
+ name: 'hereMentionAfterShortMentions',
610
+ regex: /(<\/mention-short>)(@here)(?=\b)/gm,
611
+ replacement: '$1<mention-here>$2</mention-here>',
612
+ },
613
+ {
614
+ // Use \B in this case because \b doesn't match * or ~.
615
+ // \B will match everything that \b doesn't, so it works
616
+ // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb
617
+ name: 'bold',
618
+ regex: /(?<!<[^>]*)(\b_|\B)\*(?!(?:<\/em))(?![^<]*(?:<\/pre>|<\/code>|<\/a>|<\/video>))((?![\s*])[\s\S]*?[^\s*](?<!\s))\*\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g,
619
+ replacement: (_extras, match, g1, g2) => {
620
+ if (g1.includes('_')) {
621
+ return `${g1}<strong>${g2}</strong>`;
622
+ }
623
+ return g2.includes('</pre>') || containsNonPairTag(g2) ? match : `<strong>${g2}</strong>`;
624
+ },
625
+ },
626
+ {
627
+ name: 'strikethrough',
628
+ regex: /(?<!<[^>]*)\B~((?![\s~])[\s\S]*?[^\s~](?<!\s))~\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/video>))/g,
629
+ replacement: (_extras, match, g1) => (g1.includes('</pre>') || containsNonPairTag(g1) ? match : `<del>${g1}</del>`),
630
+ },
631
+ {
632
+ name: 'newline',
633
+ regex: /\r?\n/g,
634
+ replacement: '<br />',
635
+ },
636
+ {
637
+ // We're removing <br /> because when </pre> and <br /> occur together, an extra line is added.
638
+ name: 'replacepre',
639
+ regex: /<\/pre>\s*<br\s*[/]?>/gi,
640
+ replacement: '</pre>',
641
+ },
642
+ {
643
+ // We're removing <br /> because when <h1> and <br /> occur together, an extra line is added.
644
+ name: 'replaceh1br',
645
+ regex: /<\/h1><br\s*[/]?>/gi,
646
+ replacement: '</h1>',
647
+ },
648
+ ];
649
+ /**
650
+ * The list of regex replacements to do on a HTML comment for converting it to markdown.
651
+ * Order of rules is important
652
+ */
653
+ this.htmlToMarkdownRules = [
654
+ // Used to Exclude tags
655
+ {
656
+ name: 'replacepre',
657
+ regex: /<\/pre>(.)/gi,
658
+ replacement: '</pre><br />$1',
659
+ },
660
+ {
661
+ name: 'exclude',
662
+ regex: new RegExp(['<(script|style)(?:"[^"]*"|\'[^\']*\'|[^\'">])*>([\\s\\S]*?)<\\/\\1>', '(?![^<]*(<\\/pre>|<\\/code>))(\n|\r\n)?'].join(''), 'gim'),
663
+ replacement: '',
664
+ },
665
+ {
666
+ name: 'nested',
667
+ regex: /<(pre)(?:"[^"]*"|'[^']*'|[^'">])*><(div|code)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\2><\/pre>/gi,
668
+ replacement: '<pre>$3</pre>',
669
+ },
670
+ {
671
+ name: 'newline',
672
+ // Replaces open and closing <br><br/> tags with a single <br/>
673
+ // Slack uses special <span> tag for empty lines instead of <br> tag
674
+ pre: (inputString) => inputString
675
+ .replace('<br></br>', '<br/>')
676
+ .replace('<br><br/>', '<br/>')
677
+ .replaceAll(/(<tr.*?<\/tr>)/g, '$1<br/>')
678
+ .replace('<br/></tbody>', '')
679
+ .replace(SLACK_SPAN_NEW_LINE_TAG + SLACK_SPAN_NEW_LINE_TAG, '<br/><br/><br/>')
680
+ .replace(SLACK_SPAN_NEW_LINE_TAG, '<br/><br/>'),
681
+ // Include the immediately followed newline as `<br>\n` should be equal to one \n.
682
+ regex: /<br(?:"[^"]*"|'[^']*'|[^'"><])*>\n?/gi,
683
+ replacement: '\n',
684
+ },
685
+ {
686
+ name: 'heading1',
687
+ regex: /[^\S\r\n]*<(h1)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
688
+ replacement: '<h1># $2</h1>',
689
+ },
690
+ {
691
+ name: 'listItem',
692
+ regex: /\s*<(li)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))\s*/gi,
693
+ replacement: '<li> $2</li>',
694
+ },
695
+ // Use [\s\S]* instead of .* to match newline
696
+ {
697
+ name: 'italic',
698
+ regex: /<(em|i)\b(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
699
+ replacement: '_$2_',
700
+ },
701
+ {
702
+ name: 'bold',
703
+ regex: /<(b|strong)\b(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
704
+ replacement: (extras, match, tagName, innerContent) => {
705
+ // To check if style attribute contains bold font-weight
706
+ const isBoldFromStyle = (style) => {
707
+ if (!style) {
708
+ return false;
709
+ }
710
+ const normalizedStyle = style.replaceAll(/\s/g, '');
711
+ return normalizedStyle.includes('font-weight:bold;') || normalizedStyle.includes('font-weight:700;');
712
+ };
713
+ const updateSpacesAndWrapWithAsterisksIfBold = (content, isBold) => {
714
+ const trimmedContent = content.trim();
715
+ const leadingSpace = content.startsWith(' ') ? ' ' : '';
716
+ const trailingSpace = content.endsWith(' ') ? ' ' : '';
717
+ return isBold ? `${leadingSpace}*${trimmedContent}*${trailingSpace}` : content;
718
+ };
719
+ // Determine if the outer tag is bold
720
+ const fontWeightRegex = /style="([^"]*?\bfont-weight:\s*(\d+|bold|normal)[^"]*?)"/;
721
+ const styleAttributeMatch = match.match(fontWeightRegex);
722
+ const isFontWeightBold = isBoldFromStyle(styleAttributeMatch ? styleAttributeMatch[1] : null);
723
+ const isBold = styleAttributeMatch ? isFontWeightBold : tagName === 'b' || tagName === 'strong';
724
+ // Process nested spans with potential bold style
725
+ const processedInnerContent = innerContent.replaceAll(/<span(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/span>/gi, (nestedMatch, nestedContent) => {
726
+ const nestedStyleMatch = nestedMatch.match(fontWeightRegex);
727
+ const isNestedBold = isBoldFromStyle(nestedStyleMatch ? nestedStyleMatch[1] : null);
728
+ return updateSpacesAndWrapWithAsterisksIfBold(nestedContent, isNestedBold);
729
+ });
730
+ return updateSpacesAndWrapWithAsterisksIfBold(processedInnerContent, isBold);
731
+ },
732
+ },
733
+ {
734
+ name: 'strikethrough',
735
+ regex: /<(del|s)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
736
+ replacement: '~$2~',
737
+ },
738
+ {
739
+ name: 'quote',
740
+ regex: /<(blockquote|q)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
741
+ replacement: (_extras, _match, _g1, g2) => {
742
+ // We remove the line break before heading inside quote to avoid adding extra line
743
+ let resultString = g2
744
+ .replaceAll(/\n?(<h1># )/g, '$1')
745
+ .replaceAll(/(<h1>|<\/h1>)+/g, '\n')
746
+ // Replace trim() with manually removing line breaks at the beginning and end of the string to avoid adding extra lines
747
+ .replaceAll(/^(\n)+|(\n)+$/g, '')
748
+ .split('\n');
749
+ // Wrap each string in the array with <blockquote> and </blockquote>
750
+ resultString = resultString.map((line) => `<blockquote>${line}</blockquote>`);
751
+ resultString = resultString
752
+ .map((text) => {
753
+ let modifiedText = text;
754
+ let depth;
755
+ do {
756
+ depth = (modifiedText.match(/<blockquote>/gi) || []).length;
757
+ // Need (\s)? because the server usually sends a space character after <blockquote> so we need to consume it,
758
+ // avoid being redundant because it is added in the return part
759
+ modifiedText = modifiedText.replaceAll(/<blockquote>(\s)?/gi, '');
760
+ modifiedText = modifiedText.replaceAll(/<\/blockquote>/gi, '');
761
+ } while (/<blockquote>/i.test(modifiedText));
762
+ return `${'>'.repeat(depth)} ${modifiedText}`;
763
+ })
764
+ .join('\n');
765
+ // We want to keep <blockquote> tag here and let method replaceBlockElementWithNewLine to handle the line break later
766
+ return `<blockquote>${resultString}</blockquote>`;
767
+ },
768
+ },
769
+ {
770
+ name: 'inlineCodeBlock',
771
+ regex: /<(code)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
772
+ replacement: '`$2`',
773
+ },
774
+ {
775
+ name: 'codeFence',
776
+ regex: /<(pre)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)(\n?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
777
+ replacement: (_extras, _match, _g1, g2) => `\`\`\`\n${g2}\n\`\`\``,
778
+ },
779
+ {
780
+ name: 'anchor',
781
+ regex: /<(a)[^><]*href\s*=\s*(['"])(.*?)\2(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
782
+ replacement: (_extras, _match, _g1, _g2, g3, g4) => {
783
+ const email = g3.startsWith('mailto:') ? g3.slice(7) : '';
784
+ if (email === g4) {
785
+ return email;
786
+ }
787
+ return `[${g4}](${email || g3})`;
788
+ },
789
+ },
790
+ {
791
+ name: 'image',
792
+ regex: /<img[^><]*src\s*=\s*(['"])(.*?)\1(.*?)>(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi,
793
+ /**
794
+ * @param extras - The extras object
795
+ * @param _match - The full match
796
+ * @param _g1 - The first capture group (the quote)
797
+ * @param imgSource - The second capture group - src attribute value
798
+ * @param imgAttrs - The third capture group - any attributes after src
799
+ * @returns The markdown image tag
800
+ */
801
+ replacement: (extras, _match, _g1, imgSource, imgAttrs) => {
802
+ // Extract alt attribute from imgAttrs
803
+ let altText = '';
804
+ const altRegex = /alt\s*=\s*(['"])(.*?)\1/i;
805
+ const altMatch = imgAttrs.match(altRegex);
806
+ const attrCachingFn = resolveAttributeCache(extras).attrCachingFn;
807
+ let attributes = imgAttrs;
808
+ if (altMatch) {
809
+ altText = altMatch[2];
810
+ // Remove the alt attribute from imgAttrs
811
+ attributes = attributes.replace(altRegex, '');
812
+ }
813
+ // Remove trailing slash and extra whitespace
814
+ attributes = attributes.replace(/\s*\/\s*$/, '').trim();
815
+ // Cache attributes without alt and trailing slash
816
+ if (imgAttrs && attrCachingFn && typeof attrCachingFn === 'function') {
817
+ attrCachingFn(imgSource, attributes);
818
+ }
819
+ // Return the markdown image tag
820
+ if (altText) {
821
+ return `![${altText}](${imgSource})`;
822
+ }
823
+ return `!(${imgSource})`;
824
+ },
825
+ },
826
+ {
827
+ name: 'video',
828
+ regex: /<video[^><]*data-expensify-source\s*=\s*(['"])(\S*?)\1(.*?)>([^><]*)<\/video>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi,
829
+ /**
830
+ * @param extras - The extras object
831
+ * @param match The full match
832
+ * @param _g1 The first capture group
833
+ * @param videoSource - the second capture group - video source (video URL)
834
+ * @param videoAttrs - the third capture group - video attributes (data-expensify-width, data-expensify-height, etc...)
835
+ * @param videoName - the fourth capture group will be the video file name (the text between opening and closing video tags)
836
+ * @returns The markdown video tag
837
+ */
838
+ replacement: (extras, _match, _g1, videoSource, videoAttrs, videoName) => {
839
+ const attrCachingFn = resolveAttributeCache(extras).attrCachingFn;
840
+ if (videoAttrs && attrCachingFn && typeof attrCachingFn === 'function') {
841
+ attrCachingFn(videoSource, videoAttrs);
842
+ }
843
+ if (videoName) {
844
+ return `![${videoName}](${videoSource})`;
845
+ }
846
+ return `!(${videoSource})`;
847
+ },
848
+ },
849
+ {
850
+ name: 'reportMentions',
851
+ regex: /<mention-report reportID="?(\d+)"?(?: *\/>|><\/mention-report>)/gi,
852
+ replacement: (extras, _match, g1) => {
853
+ const reportToNameMap = extras.reportIDToName;
854
+ if (!reportToNameMap || !reportToNameMap[g1]) {
855
+ ExpensiMark.Log.alert('[ExpensiMark] Missing report name', {
856
+ reportID: g1,
857
+ });
858
+ return '#Hidden';
859
+ }
860
+ return reportToNameMap[g1];
861
+ },
862
+ },
863
+ {
864
+ name: 'userMention',
865
+ regex: /(?:<mention-user accountID="?(\d+)"?(?: *\/>|><\/mention-user>))|(?:<mention-user>(.*?)<\/mention-user>)/gi,
866
+ replacement: (extras, _match, g1, g2) => {
867
+ var _a, _b;
868
+ if (g1) {
869
+ const accountToNameMap = extras.accountIDToName;
870
+ if (!accountToNameMap || !accountToNameMap[g1]) {
871
+ ExpensiMark.Log.alert('[ExpensiMark] Missing account name', {
872
+ accountID: g1,
873
+ });
874
+ return '@Hidden';
875
+ }
876
+ return `@${Str.removeSMSDomain((_b = (_a = extras.accountIDToName) === null || _a === void 0 ? void 0 : _a[g1]) !== null && _b !== void 0 ? _b : '')}`;
877
+ }
878
+ return Str.removeSMSDomain(g2);
879
+ },
880
+ },
881
+ ];
882
+ /**
883
+ * The list of rules to covert the HTML to text.
884
+ * Order of rules is important
885
+ */
886
+ this.htmlToTextRules = [
887
+ {
888
+ name: 'breakline',
889
+ regex: /<br[^>]*>/gi,
890
+ replacement: '\n',
891
+ },
892
+ {
893
+ name: 'blockquoteWrapHeadingOpen',
894
+ regex: /<blockquote><h1>/gi,
895
+ replacement: '<blockquote>',
896
+ },
897
+ {
898
+ name: 'blockquoteWrapHeadingClose',
899
+ regex: /<\/h1><\/blockquote>/gi,
900
+ replacement: '</blockquote>',
901
+ },
902
+ {
903
+ name: 'blockElementOpen',
904
+ regex: /(.|\s)<(blockquote|h1|pre)>/gi,
905
+ replacement: '$1\n',
906
+ },
907
+ {
908
+ name: 'blockElementClose',
909
+ regex: /<\/(blockquote|h1|pre)>(.|\s)/gm,
910
+ replacement: '\n$2',
911
+ },
912
+ {
913
+ name: 'removeStyle',
914
+ regex: /<style>.*?<\/style>/gi,
915
+ replacement: '',
916
+ },
917
+ {
918
+ name: 'image',
919
+ regex: /<img[^><]*src\s*=\s*(['"])(.*?)\1(?:[^><]*alt\s*=\s*(['"])(.*?)\3)?[^><]*>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi,
920
+ replacement: '[Attachment]',
921
+ },
922
+ {
923
+ name: 'video',
924
+ regex: /<video[^><]*data-expensify-source\s*=\s*(['"])(\S*?)\1(.*?)>([^><]*)<\/video>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi,
925
+ replacement: '[Attachment]',
926
+ },
927
+ {
928
+ name: 'victoryChart',
929
+ regex: /<VictoryChart\b[^>]*\/>|<VictoryChart\b[^>]*>[\s\S]*?<\/VictoryChart>/gi,
930
+ replacement: '[chart]',
931
+ },
932
+ {
933
+ name: 'otherAttachments',
934
+ regex: /<a[^><]*data-expensify-source\s*=\s*(['"])(\S*?)\1(.*?)>([^><]*)<\/a>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi,
935
+ replacement: '[Attachment]',
936
+ },
937
+ {
938
+ name: 'reportMentions',
939
+ regex: /<mention-report reportID="?(\d+)"?(?: *\/>|><\/mention-report>)/gi,
940
+ replacement: (extras, _match, g1) => {
941
+ const reportToNameMap = extras.reportIDToName;
942
+ if (!reportToNameMap || !reportToNameMap[g1]) {
943
+ ExpensiMark.Log.alert('[ExpensiMark] Missing report name', {
944
+ reportID: g1,
945
+ });
946
+ return '#Hidden';
947
+ }
948
+ return reportToNameMap[g1];
949
+ },
950
+ },
951
+ {
952
+ name: 'userMention',
953
+ regex: /(?:<mention-user accountID="?(\d+)"?(?: *\/>|><\/mention-user>))|(?:<mention-user>(.*?)<\/mention-user>)/gi,
954
+ replacement: (extras, _match, g1, g2) => {
955
+ var _a, _b;
956
+ if (g1) {
957
+ const accountToNameMap = extras.accountIDToName;
958
+ if (!accountToNameMap || !accountToNameMap[g1]) {
959
+ ExpensiMark.Log.alert('[ExpensiMark] Missing account name', {
960
+ accountID: g1,
961
+ });
962
+ return '@Hidden';
963
+ }
964
+ return `@${Str.removeSMSDomain((_b = (_a = extras.accountIDToName) === null || _a === void 0 ? void 0 : _a[g1]) !== null && _b !== void 0 ? _b : '')}`;
965
+ }
966
+ return Str.removeSMSDomain(g2);
967
+ },
968
+ },
969
+ {
970
+ name: 'stripTag',
971
+ regex: /(<([^>]+)>)/gi,
972
+ replacement: '',
973
+ },
974
+ ];
975
+ /**
976
+ * The list of rules that we have to exclude in shouldKeepWhitespaceRules list.
977
+ */
978
+ this.whitespaceRulesToDisable = ['newline', 'replacepre', 'replacebr', 'replaceh1br'];
979
+ /**
980
+ * The list of rules that have to be applied when shouldKeepWhitespace flag is true.
981
+ * @param rule - The rule to check.
982
+ * @returns true if the rule should be applied, otherwise false.
983
+ */
984
+ this.filterRules = (rule) => !this.whitespaceRulesToDisable.includes(rule.name);
985
+ /**
986
+ * Filters rules to determine which should keep whitespace.
987
+ * @returns The filtered rules.
988
+ */
989
+ this.shouldKeepWhitespaceRules = this.rules.filter(this.filterRules);
990
+ /**
991
+ * maxQuoteDepth is the maximum depth of nested quotes that we want to support.
992
+ */
993
+ this.maxQuoteDepth = 3;
994
+ /**
995
+ * currentQuoteDepth is the current depth of nested quotes that we are processing.
996
+ */
997
+ this.currentQuoteDepth = 0;
998
+ }
999
+ /**
1000
+ * Retrieves the HTML ruleset based on the provided filter rules, disabled rules, and shouldKeepRawInput flag.
1001
+ * @param filterRules - An array of rule names to filter the ruleset.
1002
+ * @param disabledRules - An array of rule names to disable in the ruleset.
1003
+ * @param shouldKeepRawInput - A boolean flag indicating whether to keep raw input.
1004
+ */
1005
+ getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput) {
1006
+ let rules = this.rules;
1007
+ const hasRuleName = (rule) => filterRules.includes(rule.name);
1008
+ const hasDisabledRuleName = (rule) => !disabledRules.includes(rule.name);
1009
+ if (shouldKeepRawInput) {
1010
+ rules = this.shouldKeepWhitespaceRules;
1011
+ }
1012
+ if (filterRules.length > 0) {
1013
+ rules = this.rules.filter(hasRuleName);
1014
+ }
1015
+ if (disabledRules.length > 0) {
1016
+ rules = rules.filter(hasDisabledRuleName);
1017
+ }
1018
+ return rules;
1019
+ }
1020
+ /**
1021
+ * Replaces markdown with html elements
1022
+ *
1023
+ * @param text - Text to parse as markdown
1024
+ * @param [options] - Options to customize the markdown parser
1025
+ * @param [options.filterRules=[]] - An array of name of rules as defined in this class.
1026
+ * If not provided, all available rules will be applied.
1027
+ * @param [options.shouldEscapeText=true] - Whether or not the text should be escaped
1028
+ * @param [options.disabledRules=[]] - An array of name of rules as defined in this class.
1029
+ * If not provided, all available rules will be applied. If provided, the rules in the array will be skipped.
1030
+ */
1031
+ replace(text, { filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = [], extras = EXTRAS_DEFAULT } = {}) {
1032
+ if (!text) {
1033
+ return '';
1034
+ }
1035
+ // Extract VictoryChart blocks to preserve their markup during processing.
1036
+ // Only safe for trusted server input - user input must be escaped to prevent XSS.
1037
+ const { text: textWithPlaceholders, tags: victoryChartTags } = shouldEscapeText ? { text, tags: [] } : extractVictoryChartTags(text);
1038
+ // This ensures that any html the user puts into the comment field shows as raw html
1039
+ let replacedText = shouldEscapeText ? Utils.escapeText(textWithPlaceholders) : textWithPlaceholders;
1040
+ const rules = this.getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput);
1041
+ const processRule = (rule) => {
1042
+ // Pre-process text before applying regex
1043
+ if (rule.pre) {
1044
+ replacedText = rule.pre(replacedText);
1045
+ }
1046
+ if (rule.shouldSkipProcessing && rule.shouldSkipProcessing(replacedText)) {
1047
+ return;
1048
+ }
1049
+ const replacement = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement;
1050
+ if ('process' in rule) {
1051
+ replacedText = rule.process(replacedText, replacement, shouldKeepRawInput);
1052
+ }
1053
+ else {
1054
+ replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, replacement);
1055
+ }
1056
+ // Post-process text after applying regex
1057
+ if (rule.post) {
1058
+ replacedText = rule.post(replacedText);
1059
+ }
1060
+ };
1061
+ try {
1062
+ for (const rule of rules) {
1063
+ processRule(rule);
1064
+ }
1065
+ }
1066
+ catch (e) {
1067
+ ExpensiMark.Log.alert('Error replacing text with html in ExpensiMark.replace', { error: e });
1068
+ // We want to return text without applying rules if exception occurs during replacing
1069
+ return shouldEscapeText ? Utils.escapeText(text) : text;
1070
+ }
1071
+ return restoreVictoryChartTags(replacedText, victoryChartTags);
1072
+ }
1073
+ /**
1074
+ * Checks matched URLs for validity and replace valid links with html elements
1075
+ */
1076
+ modifyTextForUrlLinks(regex, textToCheck, replacement) {
1077
+ let match = regex.exec(textToCheck);
1078
+ let replacedText = '';
1079
+ let startIndex = 0;
1080
+ while (match !== null) {
1081
+ // We end the link at the last closing parenthesis that matches an opening parenthesis because unmatched closing parentheses are unlikely to be in the url
1082
+ // and can be part of markdown for example
1083
+ let unmatchedOpenParentheses = 0;
1084
+ let url = match[2];
1085
+ for (let i = 0; i < url.length; i++) {
1086
+ if (url[i] === '(') {
1087
+ unmatchedOpenParentheses++;
1088
+ }
1089
+ else if (url[i] === ')') {
1090
+ // Unmatched closing parenthesis
1091
+ if (unmatchedOpenParentheses <= 0) {
1092
+ const numberOfCharsToRemove = url.length - i;
1093
+ match[0] = match[0].substr(0, match[0].length - numberOfCharsToRemove);
1094
+ url = url.substr(0, url.length - numberOfCharsToRemove);
1095
+ break;
1096
+ }
1097
+ unmatchedOpenParentheses--;
1098
+ }
1099
+ }
1100
+ // Because we are removing ) parenthesis, some special characters that shouldn't be in the href are in the href
1101
+ // For example google.com/toto.) is accepted by the regular expression above and we remove the ) parenthesis, so the link becomes google.com/toto. which is not a valid link
1102
+ // In that case we should also remove the "."
1103
+ // Those characters should only be remove from the url if this url doesn't have a parameter or a fragment
1104
+ if (!url.includes('?') && !url.includes('#')) {
1105
+ let numberOfCharsToRemove = 0;
1106
+ for (let i = url.length - 1; i >= 0; i--) {
1107
+ if (Constants.CONST.SPECIAL_CHARS_TO_REMOVE.includes(url[i])) {
1108
+ numberOfCharsToRemove++;
1109
+ }
1110
+ else {
1111
+ break;
1112
+ }
1113
+ }
1114
+ if (numberOfCharsToRemove) {
1115
+ match[0] = match[0].substring(0, match[0].length - numberOfCharsToRemove);
1116
+ url = url.substring(0, url.length - numberOfCharsToRemove);
1117
+ }
1118
+ }
1119
+ replacedText = replacedText.concat(textToCheck.substr(startIndex, match.index - startIndex));
1120
+ // We want to avoid matching domains in email addresses so we don't render them as URLs,
1121
+ // but we need to check if there are valid URLs after the email address and render them accordingly,
1122
+ // e.g. test@expensify.com/https://www.test.com
1123
+ let isDoneMatching = false;
1124
+ let shouldApplyAutoLinkAgain = true;
1125
+ // If we find a URL with a leading @ sign, we need look for other domains in the rest of the string
1126
+ if (match.index !== 0 && textToCheck[match.index - 1] === '@') {
1127
+ const domainRegex = /^(([a-z-0-9]+\.)+[a-z]{2,})(\S*)/i;
1128
+ const domainMatch = domainRegex.exec(url);
1129
+ // If we find another domain in the remainder of the string, we apply the auto link rule again and set a flag to avoid re-doing below.
1130
+ if (domainMatch !== null && domainMatch[3] !== '') {
1131
+ replacedText = replacedText.concat(domainMatch[1] + this.replace(domainMatch[3], { filterRules: ['autolink'] }));
1132
+ shouldApplyAutoLinkAgain = false;
1133
+ }
1134
+ else {
1135
+ // Otherwise, we're done applying rules
1136
+ isDoneMatching = true;
1137
+ }
1138
+ }
1139
+ // We don't want to apply link rule if match[1] contains the code block inside the [] of the markdown e.g. [```example```](https://example.com)
1140
+ // or if match[1] is multiline text preceeded by markdown heading, e.g., # [example\nexample\nexample](https://example.com)
1141
+ if (isDoneMatching || match[1].includes('</pre>') || match[1].includes('</h1>')) {
1142
+ replacedText = replacedText.concat(textToCheck.substr(match.index, match[0].length));
1143
+ }
1144
+ else if (shouldApplyAutoLinkAgain) {
1145
+ const urlRegex = new RegExp(`^${UrlPatterns.LOOSE_URL_REGEX}$|^${UrlPatterns.URL_REGEX}$`, 'i');
1146
+ // `match[1]` contains the text inside the [] of the markdown e.g. [example](https://example.com)
1147
+ // At the entry of function this.replace, text is already escaped due to the rules that precede the link
1148
+ // rule (eg, codeFence, inlineCodeBlock, email), so we don't need to escape the text again here.
1149
+ // If the text `match[1]` exactly matches a URL, we skip translating the filterRules
1150
+ // So that special characters such as ['_', '*', '~'] are preserved in the text.
1151
+ const linkText = urlRegex.test(match[1])
1152
+ ? match[1]
1153
+ : this.replace(match[1], {
1154
+ filterRules: ['bold', 'strikethrough', 'italic', 'newline'],
1155
+ shouldEscapeText: false,
1156
+ });
1157
+ replacedText = replacedText.concat(replacement(EXTRAS_DEFAULT, match[0], linkText, url));
1158
+ }
1159
+ startIndex = match.index + match[0].length;
1160
+ // Now we move to the next match that the js regex found in the text
1161
+ match = regex.exec(textToCheck);
1162
+ }
1163
+ if (startIndex < textToCheck.length) {
1164
+ replacedText = replacedText.concat(textToCheck.substr(startIndex));
1165
+ }
1166
+ return replacedText;
1167
+ }
1168
+ /**
1169
+ * Checks matched Emails for validity and replace valid links with html elements
1170
+ */
1171
+ modifyTextForEmailLinks(regex, textToCheck, replacement, shouldKeepRawInput) {
1172
+ let match = regex.exec(textToCheck);
1173
+ let replacedText = '';
1174
+ let startIndex = 0;
1175
+ while (match !== null) {
1176
+ replacedText = replacedText.concat(textToCheck.substr(startIndex, match.index - startIndex));
1177
+ // `match[1]` contains the text inside []. Text is already escaped due to the rules
1178
+ // that precede the link rule (eg, codeFence, inlineCodeBlock, email).
1179
+ const linkText = this.replace(match[1], {
1180
+ filterRules: ['bold', 'strikethrough', 'italic'],
1181
+ shouldEscapeText: false,
1182
+ });
1183
+ // rawInputReplacement needs to be called with additional parameters from match
1184
+ const replacedMatch = shouldKeepRawInput ? replacement(EXTRAS_DEFAULT, match[0], linkText, match[2], match[3]) : replacement(EXTRAS_DEFAULT, match[0], linkText, match[3]);
1185
+ replacedText = replacedText.concat(replacedMatch);
1186
+ startIndex = match.index + match[0].length;
1187
+ // Line breaks (`\n`) followed by empty contents are already removed
1188
+ // but line breaks inside contents should be parsed to <br/> to skip `autoEmail` rule
1189
+ replacedText = this.replace(replacedText, {
1190
+ filterRules: ['newline'],
1191
+ shouldEscapeText: false,
1192
+ });
1193
+ // Now we move to the next match that the js regex found in the text
1194
+ match = regex.exec(textToCheck);
1195
+ }
1196
+ if (startIndex < textToCheck.length) {
1197
+ replacedText = replacedText.concat(textToCheck.substr(startIndex));
1198
+ }
1199
+ return replacedText;
1200
+ }
1201
+ /**
1202
+ * Unpacks nested quote HTML tags that have been packed by the 'quote' rule in this.rules for shouldKeepRawInput = false
1203
+ *
1204
+ * For example, it parses the following HTML:
1205
+ * <blockquote>
1206
+ * quote 1
1207
+ * <blockquote>
1208
+ * quote 2
1209
+ * </blockquote>
1210
+ * quote 3
1211
+ * </blockquote>
1212
+ *
1213
+ * into:
1214
+ * <blockquote> quote 1</blockquote>
1215
+ * <blockquote><blockquote> quote 2</blockquote>
1216
+ * <blockquote> quote 3</blockquote>
1217
+ *
1218
+ * Note that there will always be only a single closing tag, even if multiple opening tags exist.
1219
+ * Only one closing tag is needed to detect if a nested quote has ended.
1220
+ */
1221
+ static unpackNestedQuotes(text) {
1222
+ let parsedText = text.replaceAll(/((<\/blockquote>)+(<br \/>)?)|(<br \/>)/g, (match) => `${match}</split>`);
1223
+ const splittedText = parsedText.split('</split>');
1224
+ if (splittedText.length > 0 && splittedText[splittedText.length - 1] === '') {
1225
+ splittedText.pop();
1226
+ }
1227
+ let count = 0;
1228
+ parsedText = splittedText
1229
+ .map((line) => {
1230
+ const hasBR = line.endsWith('<br />');
1231
+ if (line === '' && count === 0) {
1232
+ return '';
1233
+ }
1234
+ const textLine = line.replaceAll(/(<br \/>)$/g, '');
1235
+ if (textLine.startsWith('<blockquote>')) {
1236
+ count += (textLine.match(/<blockquote>/g) || []).length;
1237
+ }
1238
+ if (textLine.endsWith('</blockquote>')) {
1239
+ count -= (textLine.match(/<\/blockquote>/g) || []).length;
1240
+ if (count > 0) {
1241
+ return `${textLine}${'<blockquote>'.repeat(count)}`;
1242
+ }
1243
+ }
1244
+ if (count > 0) {
1245
+ return `${textLine}${'</blockquote>'}${'<blockquote>'.repeat(count)}`;
1246
+ }
1247
+ return textLine + (hasBR ? '<br />' : '');
1248
+ })
1249
+ .join('');
1250
+ return parsedText;
1251
+ }
1252
+ /**
1253
+ * Replaces HTML with markdown
1254
+ */
1255
+ htmlToMarkdown(htmlString, extras = EXTRAS_DEFAULT) {
1256
+ if (!htmlString) {
1257
+ return '';
1258
+ }
1259
+ let generatedMarkdown = htmlString;
1260
+ const body = /<(body)(?:"[^"]*"|'[^']*'|[^'"><])*>(?:\n|\r\n)?([\s\S]*?)(?:\n|\r\n)?<\/\1>(?![^<]*(<\/pre>|<\/code>))/im;
1261
+ const parseBodyTag = generatedMarkdown.match(body);
1262
+ // If body tag is found then use the content of body rather than the whole HTML
1263
+ if (parseBodyTag) {
1264
+ generatedMarkdown = parseBodyTag[2];
1265
+ }
1266
+ generatedMarkdown = ExpensiMark.unpackNestedQuotes(generatedMarkdown);
1267
+ // Extract VictoryChart blocks before HTML stripping, then restore them.
1268
+ const { text: textWithPlaceholders, tags: victoryChartTags } = extractVictoryChartTags(generatedMarkdown);
1269
+ generatedMarkdown = textWithPlaceholders;
1270
+ const processRule = (rule) => {
1271
+ // Pre-processes input HTML before applying regex
1272
+ if (rule.pre) {
1273
+ generatedMarkdown = rule.pre(generatedMarkdown);
1274
+ }
1275
+ generatedMarkdown = replaceTextWithExtras(generatedMarkdown, rule.regex, extras, rule.replacement);
1276
+ };
1277
+ for (const rule of this.htmlToMarkdownRules) {
1278
+ processRule(rule);
1279
+ }
1280
+ const decoded = Str.htmlDecode(replaceBlockElementWithNewLine(generatedMarkdown));
1281
+ return restoreVictoryChartTags(decoded, victoryChartTags);
1282
+ }
1283
+ /**
1284
+ * Convert HTML to text
1285
+ */
1286
+ htmlToText(htmlString, extras = EXTRAS_DEFAULT) {
1287
+ let replacedText = htmlString;
1288
+ const processRule = (rule) => {
1289
+ replacedText = replaceTextWithExtras(replacedText, rule.regex, extras, rule.replacement);
1290
+ };
1291
+ for (const rule of this.htmlToTextRules) {
1292
+ processRule(rule);
1293
+ }
1294
+ // Unescaping because the text is escaped in 'replace' function
1295
+ // We use 'htmlDecode' instead of 'unescape' to replace entities like '&#32;'
1296
+ replacedText = Str.htmlDecode(replacedText);
1297
+ return replacedText;
1298
+ }
1299
+ /**
1300
+ * Main text to html 'quote' parsing logic.
1301
+ * Removes &gt;( ) from text and recursively calls replace function to process nested quotes and build blockquote HTML result.
1302
+ * @param shouldKeepRawInput determines if the raw input should be kept for nested quotes.
1303
+ */
1304
+ replaceQuoteText(text, shouldKeepRawInput) {
1305
+ let isStartingWithSpace = false;
1306
+ const handleMatch = (_match, g2) => {
1307
+ isStartingWithSpace = !!g2;
1308
+ return '';
1309
+ };
1310
+ const textToReplace = text.replaceAll(/^(?:&gt;|>)( )?/gm, handleMatch);
1311
+ const filterRules = ['heading1'];
1312
+ // If we don't reach the max quote depth, we allow the recursive call to process other possible quotes
1313
+ if (this.currentQuoteDepth < this.maxQuoteDepth - 1 && !isStartingWithSpace) {
1314
+ filterRules.push('quote');
1315
+ this.currentQuoteDepth++;
1316
+ }
1317
+ const replacedText = this.replace(textToReplace, {
1318
+ filterRules,
1319
+ shouldEscapeText: false,
1320
+ shouldKeepRawInput,
1321
+ });
1322
+ this.currentQuoteDepth = 0;
1323
+ return { replacedText, shouldAddSpace: isStartingWithSpace };
1324
+ }
1325
+ /**
1326
+ * @returns array or undefined if exception occurs when executing regex matching
1327
+ */
1328
+ extractLinksInMarkdownComment(comment) {
1329
+ try {
1330
+ const htmlString = this.replace(comment, { filterRules: ['link'] });
1331
+ // We use same anchor tag template as link and autolink rules to extract link
1332
+ const regex = new RegExp(`<a href="${UrlPatterns.MARKDOWN_URL_REGEX}" target="_blank" rel="noreferrer noopener">`, 'gi');
1333
+ const matches = [...htmlString.matchAll(regex)];
1334
+ // Element 1 from match is the regex group if it exists which contains the link URLs
1335
+ const sanitizeMatch = (match) => Str.sanitizeURL(match[1]);
1336
+ const links = matches.map(sanitizeMatch);
1337
+ return links;
1338
+ }
1339
+ catch (e) {
1340
+ ExpensiMark.Log.alert('Error parsing url in ExpensiMark.extractLinksInMarkdownComment', { error: e });
1341
+ return undefined;
1342
+ }
1343
+ }
1344
+ /**
1345
+ * Compares two markdown comments and returns a list of the links removed in a new comment.
1346
+ */
1347
+ getRemovedMarkdownLinks(oldComment, newComment) {
1348
+ const linksInOld = this.extractLinksInMarkdownComment(oldComment);
1349
+ const linksInNew = this.extractLinksInMarkdownComment(newComment);
1350
+ return linksInOld === undefined || linksInNew === undefined ? [] : linksInOld.filter((link) => !linksInNew.includes(link));
1351
+ }
1352
+ /**
1353
+ * Escapes the content of an HTML attribute value
1354
+ * @param content - string content that possible contains HTML
1355
+ * @returns original MD content escaped for use in HTML attribute value
1356
+ */
1357
+ escapeAttributeContent(content) {
1358
+ let originalContent = this.htmlToMarkdown(content);
1359
+ if (content === originalContent) {
1360
+ return content;
1361
+ }
1362
+ // When the attribute contains HTML and is converted back to MD we need to re-escape it to avoid
1363
+ // illegal attribute value characters like `," or ' which might break the HTML
1364
+ originalContent = Str.replaceAll(originalContent, '\n', '');
1365
+ return Utils.escapeText(originalContent);
1366
+ }
1367
+ }
1368
+ ExpensiMark.Log = new Logger({
1369
+ serverLoggingCallback: () => undefined,
1370
+ // eslint-disable-next-line no-console
1371
+ clientLoggingCallback: (message) => console.warn(message),
1372
+ isDebug: true,
1373
+ });
1374
+ export default ExpensiMark;