expensify-common 1.0.1 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +15 -6
  2. package/dist/API.d.ts +11 -0
  3. package/{lib/API.jsx → dist/API.js} +230 -165
  4. package/dist/APIDeferred.d.ts +7 -0
  5. package/{lib/APIDeferred.jsx → dist/APIDeferred.js} +35 -53
  6. package/dist/BrowserDetect.d.ts +19 -0
  7. package/dist/BrowserDetect.js +107 -0
  8. package/dist/CONST.d.ts +813 -0
  9. package/{lib/CONST.jsx → dist/CONST.js} +245 -167
  10. package/dist/Cookie.d.ts +68 -0
  11. package/{lib/Cookie.jsx → dist/Cookie.js} +23 -36
  12. package/dist/CredentialsWrapper.d.ts +32 -0
  13. package/dist/CredentialsWrapper.js +52 -0
  14. package/dist/Device.d.ts +8 -0
  15. package/dist/Device.js +15 -0
  16. package/dist/ExpenseRule.d.ts +39 -0
  17. package/{lib/ExpenseRule.jsx → dist/ExpenseRule.js} +12 -14
  18. package/dist/ExpensiMark.d.ts +142 -0
  19. package/dist/ExpensiMark.js +1026 -0
  20. package/dist/Func.d.ts +40 -0
  21. package/{lib/Func.jsx → dist/Func.js} +19 -25
  22. package/dist/Log.d.ts +3 -0
  23. package/dist/Log.js +41 -0
  24. package/dist/Logger.d.ts +77 -0
  25. package/dist/Logger.js +126 -0
  26. package/dist/Network.d.ts +6 -0
  27. package/{lib/Network.jsx → dist/Network.js} +48 -45
  28. package/dist/Num.d.ts +95 -0
  29. package/{lib/Num.jsx → dist/Num.js} +20 -40
  30. package/dist/PageEvent.d.ts +25 -0
  31. package/dist/PageEvent.js +28 -0
  32. package/dist/PubSub.d.ts +2 -0
  33. package/{lib/PubSub.jsx → dist/PubSub.js} +27 -39
  34. package/dist/ReportHistoryStore.d.ts +64 -0
  35. package/dist/ReportHistoryStore.js +261 -0
  36. package/dist/Templates.d.ts +2 -0
  37. package/{lib/Templates.jsx → dist/Templates.js} +33 -48
  38. package/dist/Url.d.ts +22 -0
  39. package/dist/Url.js +30 -0
  40. package/dist/components/CopyText.d.ts +45 -0
  41. package/{lib/components/CopyText.jsx → dist/components/CopyText.js} +16 -23
  42. package/dist/components/StepProgressBar.d.ts +22 -0
  43. package/dist/components/StepProgressBar.js +68 -0
  44. package/dist/components/form/element/combobox.d.ts +237 -0
  45. package/{lib → dist}/components/form/element/combobox.js +361 -516
  46. package/dist/components/form/element/dropdown.d.ts +35 -0
  47. package/dist/components/form/element/dropdown.js +66 -0
  48. package/dist/components/form/element/dropdownItem.d.ts +55 -0
  49. package/dist/components/form/element/dropdownItem.js +118 -0
  50. package/dist/components/form/element/onOffSwitch.d.ts +94 -0
  51. package/dist/components/form/element/onOffSwitch.js +195 -0
  52. package/dist/components/form/element/switch.d.ts +58 -0
  53. package/{lib → dist}/components/form/element/switch.js +29 -66
  54. package/dist/fastMerge.d.ts +9 -0
  55. package/dist/fastMerge.js +77 -0
  56. package/dist/index.d.ts +19 -0
  57. package/dist/index.js +71 -0
  58. package/dist/jquery.expensifyIframify.d.ts +10 -0
  59. package/{lib → dist}/jquery.expensifyIframify.js +52 -93
  60. package/dist/mixins/PubSub.d.ts +20 -0
  61. package/{lib/mixins/PubSub.jsx → dist/mixins/PubSub.js} +12 -11
  62. package/dist/mixins/extraClasses.d.ts +8 -0
  63. package/{lib → dist}/mixins/extraClasses.js +8 -12
  64. package/dist/mixins/validationClasses.d.ts +12 -0
  65. package/dist/mixins/validationClasses.js +58 -0
  66. package/dist/str.d.ts +613 -0
  67. package/{lib → dist}/str.js +176 -160
  68. package/dist/tlds.d.ts +2 -0
  69. package/dist/tlds.js +4 -0
  70. package/dist/utils.d.ts +5 -0
  71. package/dist/utils.js +13 -0
  72. package/package.json +47 -18
  73. package/.editorconfig +0 -34
  74. package/.eslintrc.js +0 -11
  75. package/.github/CODEOWNERS +0 -2
  76. package/.github/CONTRIBUTING.md +0 -163
  77. package/.github/ISSUE_TEMPLATE.md +0 -3
  78. package/.github/PULL_REQUEST_TEMPLATE.md +0 -14
  79. package/.github/workflows/cla.yml +0 -39
  80. package/.github/workflows/lint.yml +0 -29
  81. package/.github/workflows/test.yml +0 -29
  82. package/CLA.md +0 -20
  83. package/Gruntfile.js +0 -13
  84. package/__tests__/ExpensiMark-test.js +0 -340
  85. package/__tests__/Logger-test.js +0 -55
  86. package/__tests__/Str-test.js +0 -53
  87. package/babel.config.js +0 -12
  88. package/grunt/configloader.js +0 -17
  89. package/grunt/configs/chokidar.js +0 -23
  90. package/grunt/configs/eslint.js +0 -15
  91. package/grunt/task/watch.js +0 -3
  92. package/grunt/taskloader.js +0 -25
  93. package/lib/BrowserDetect.jsx +0 -91
  94. package/lib/ExpensiMark.js +0 -253
  95. package/lib/Log.jsx +0 -36
  96. package/lib/Logger.jsx +0 -154
  97. package/lib/PageEvent.jsx +0 -23
  98. package/lib/ReportHistoryStore.jsx +0 -194
  99. package/lib/components/StepProgressBar.js +0 -49
  100. package/lib/components/form/element/dropdown.js +0 -90
  101. package/lib/components/form/element/dropdownItem.js +0 -178
  102. package/lib/components/form/element/onOffSwitch.jsx +0 -229
  103. package/lib/mixins/validationClasses.js +0 -23
  104. package/lib/tlds.jsx +0 -3
@@ -0,0 +1,1026 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ const _ = __importStar(require("underscore"));
30
+ const str_1 = __importDefault(require("./str"));
31
+ const Constants = __importStar(require("./CONST"));
32
+ const UrlPatterns = __importStar(require("./Url"));
33
+ const Log_1 = __importDefault(require("./Log"));
34
+ const MARKDOWN_LINK_REGEX = new RegExp(`\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)]\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
35
+ const MARKDOWN_IMAGE_REGEX = new RegExp(`\\!(?:\\[([^\\][]*(?:\\[[^\\][]*][^\\][]*)*)])?\\(${UrlPatterns.MARKDOWN_URL_REGEX}\\)(?![^<]*(<\\/pre>|<\\/code>))`, 'gi');
36
+ 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>';
37
+ class ExpensiMark {
38
+ constructor() {
39
+ /**
40
+ * The list of regex replacements to do on a comment. Check the link regex is first so links are processed
41
+ * before other delimiters
42
+ *
43
+ * @type {Object[]}
44
+ */
45
+ this.rules = [
46
+ // Apply the emoji first avoid applying any other formatting rules inside of it
47
+ {
48
+ name: 'emoji',
49
+ regex: Constants.CONST.REG_EXP.EMOJI_RULE,
50
+ replacement: (match) => `<emoji>${match}</emoji>`,
51
+ },
52
+ /**
53
+ * Apply the code-fence to avoid replacing anything inside of it that we're not supposed to
54
+ * (aka any rule with the '(?![^<]*<\/pre>)' avoidance in it
55
+ */
56
+ {
57
+ name: 'codeFence',
58
+ // &#x60; is a backtick symbol we are matching on three of them before then after a new line character
59
+ regex: /(&#x60;&#x60;&#x60;(?:\r\n|\n))((?:\s*?(?!(?:\r\n|\n)?&#x60;&#x60;&#x60;(?!&#x60;))[\S])+\s*?(?:\r\n|\n))(&#x60;&#x60;&#x60;)/g,
60
+ // We're using a function here to perform an additional replace on the content
61
+ // inside the backticks because Android is not able to use <pre> tags and does
62
+ // not respect whitespace characters at all like HTML does. We do not want to mess
63
+ // with the new lines here since they need to be converted into <br>. And we don't
64
+ // want to do this anywhere else since that would break HTML.
65
+ // &nbsp; will create styling issues so use &#32;
66
+ replacement: (match, __, textWithinFences) => {
67
+ const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, '&#32;');
68
+ return `<pre>${group}</pre>`;
69
+ },
70
+ rawInputReplacement: (match, __, textWithinFences) => {
71
+ const withinFences = match.replace(/(?:&#x60;&#x60;&#x60;)([\s\S]*?)(?:&#x60;&#x60;&#x60;)/g, '$1');
72
+ const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, '&#32;');
73
+ return `<pre data-code-raw="${withinFences}">${group}</pre>`;
74
+ },
75
+ },
76
+ /**
77
+ * Apply inline code-block to avoid applying any other formatting rules inside of it,
78
+ * like we do for the multi-line code-blocks
79
+ */
80
+ {
81
+ name: 'inlineCodeBlock',
82
+ // Use the url escaped version of a backtick (`) symbol. Mobile platforms do not support lookbehinds,
83
+ // so capture the first and third group and place them in the replacement.
84
+ // but we should not replace backtick symbols if they include <pre> tags between them.
85
+ regex: /(\B|_|)&#x60;(.*?(?![&#x60;])\S.*?)&#x60;(\B|_|)(?!&#x60;|[^<]*<\/pre>)/gm,
86
+ replacement: '$1<code>$2</code>$3',
87
+ },
88
+ /**
89
+ * Converts markdown style links to anchor tags e.g. [Expensify](concierge@expensify.com)
90
+ * We need to convert before the auto email link rule and the manual link rule since it will not try to
91
+ * create a link from an existing anchor tag.
92
+ */
93
+ {
94
+ name: 'email',
95
+ process: (textToProcess, replacement, shouldKeepRawInput) => {
96
+ const regex = new RegExp(`(?!\\[\\s*\\])\\[([^[\\]]*)]\\((mailto:)?${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}\\)`, 'gim');
97
+ return this.modifyTextForEmailLinks(regex, textToProcess, replacement, shouldKeepRawInput);
98
+ },
99
+ replacement: (match, g1, g2) => {
100
+ if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
101
+ return match;
102
+ }
103
+ const label = g1.trim();
104
+ const href = `mailto:${g2}`;
105
+ const formattedLabel = label === href ? g2 : label;
106
+ return `<a href="${href}">${formattedLabel}</a>`;
107
+ },
108
+ rawInputReplacement: (match, g1, g2, g3) => {
109
+ if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
110
+ return match;
111
+ }
112
+ const dataRawHref = g2 ? g2 + g3 : g3;
113
+ const href = `mailto:${g3}`;
114
+ return `<a href="${href}" data-raw-href="${dataRawHref}" data-link-variant="labeled">${g1}</a>`;
115
+ },
116
+ },
117
+ {
118
+ name: 'heading1',
119
+ process: (textToProcess, replacement, shouldKeepRawInput = false) => {
120
+ const regexp = shouldKeepRawInput ? /^# ( *(?! )(?:(?!<pre>|\n|\r\n).)+)/gm : /^# +(?! )((?:(?!<pre>|\n|\r\n).)+)/gm;
121
+ return textToProcess.replace(regexp, replacement);
122
+ },
123
+ replacement: '<h1>$1</h1>',
124
+ },
125
+ /**
126
+ * Converts markdown style images to img tags e.g. ![Expensify](https://www.expensify.com/attachment.png)
127
+ * We need to convert before linking rules since they will not try to create a link from an existing img
128
+ * tag.
129
+ * Additional sanitization is done to the alt attribute to prevent parsing it further to html by later
130
+ * rules.
131
+ */
132
+ {
133
+ name: 'image',
134
+ regex: MARKDOWN_IMAGE_REGEX,
135
+ replacement: (match, g1, g2) => `<img src="${str_1.default.sanitizeURL(g2)}"${g1 ? ` alt="${this.escapeAttributeContent(g1)}"` : ''} />`,
136
+ rawInputReplacement: (match, g1, g2) => `<img src="${str_1.default.sanitizeURL(g2)}"${g1 ? ` alt="${this.escapeAttributeContent(g1)}"` : ''} data-raw-href="${g2}" data-link-variant="${typeof g1 === 'string' ? 'labeled' : 'auto'}" />`,
137
+ },
138
+ /**
139
+ * Converts markdown style links to anchor tags e.g. [Expensify](https://www.expensify.com)
140
+ * We need to convert before the autolink rule since it will not try to create a link
141
+ * from an existing anchor tag.
142
+ */
143
+ {
144
+ name: 'link',
145
+ process: (textToProcess, replacement) => this.modifyTextForUrlLinks(MARKDOWN_LINK_REGEX, textToProcess, replacement),
146
+ replacement: (match, g1, g2) => {
147
+ if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
148
+ return match;
149
+ }
150
+ return `<a href="${str_1.default.sanitizeURL(g2)}" target="_blank" rel="noreferrer noopener">${g1.trim()}</a>`;
151
+ },
152
+ rawInputReplacement: (match, g1, g2) => {
153
+ if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
154
+ return match;
155
+ }
156
+ return `<a href="${str_1.default.sanitizeURL(g2)}" data-raw-href="${g2}" data-link-variant="labeled" target="_blank" rel="noreferrer noopener">${g1.trim()}</a>`;
157
+ },
158
+ },
159
+ /**
160
+ * Apply the hereMention first because the string @here is still a valid mention for the userMention regex.
161
+ * This ensures that the hereMention is always considered first, even if it is followed by a valid
162
+ * userMention.
163
+ *
164
+ * Also, apply the mention rule after email/link to prevent mention appears in an email/link.
165
+ */
166
+ {
167
+ name: 'hereMentions',
168
+ regex: /([a-zA-Z0-9.!$%&+/=?^`{|}_-]?)(@here)([.!$%&+/=?^`{|}_-]?)(?=\b)(?!([\w'#%+-]*@(?:[a-z\d-]+\.)+[a-z]{2,}(?:\s|$|@here))|((?:(?!<a).)+)?<\/a>|[^<]*(<\/pre>|<\/code>))/gm,
169
+ replacement: (match, g1, g2, g3) => {
170
+ if (!str_1.default.isValidMention(match)) {
171
+ return match;
172
+ }
173
+ return `${g1}<mention-here>${g2}</mention-here>${g3}`;
174
+ },
175
+ },
176
+ /**
177
+ * A room mention is a string that starts with the '#' symbol and is followed by a valid room name.
178
+ *
179
+ * Note: We are allowing mentions in a format of #room-name The room name can contain any
180
+ * combination of letters and hyphens
181
+ */
182
+ {
183
+ name: 'reportMentions',
184
+ regex: /(?<![^ \n*~_])(#[\p{Ll}0-9-]{1,80})/gimu,
185
+ replacement: '<mention-report>$1</mention-report>',
186
+ },
187
+ /**
188
+ * This regex matches a valid user mention in a string.
189
+ * A user mention is a string that starts with the '@' symbol and is followed by a valid user's primary
190
+ * login
191
+ *
192
+ * Note: currently we are only allowing mentions in a format of @+19728974297 (E.164 format phone number)
193
+ * and @username@example.com The username can contain any combination of alphanumeric letters, numbers, and
194
+ * underscores
195
+ */
196
+ {
197
+ name: 'userMentions',
198
+ regex: new RegExp(`(@here|[a-zA-Z0-9.!$%&+=?^\`{|}-]?)(@${Constants.CONST.REG_EXP.EMAIL_PART}|@${Constants.CONST.REG_EXP.PHONE_PART})(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>))`, 'gim'),
199
+ replacement: (match, g1, g2) => {
200
+ if (!str_1.default.isValidMention(match)) {
201
+ return match;
202
+ }
203
+ const phoneRegex = new RegExp(`^@${Constants.CONST.REG_EXP.PHONE_PART}$`);
204
+ return `${g1}<mention-user>${g2}${phoneRegex.test(g2) ? `@${Constants.CONST.SMS.DOMAIN}` : ''}</mention-user>`;
205
+ },
206
+ rawInputReplacement: (match, g1, g2) => {
207
+ if (!str_1.default.isValidMention(match)) {
208
+ return match;
209
+ }
210
+ return `${g1}<mention-user>${g2}</mention-user>`;
211
+ },
212
+ },
213
+ {
214
+ name: 'hereMentionAfterUserMentions',
215
+ regex: /(<\/mention-user>)(@here)(?=\b)/gm,
216
+ replacement: '$1<mention-here>$2</mention-here>',
217
+ },
218
+ /**
219
+ * Automatically link urls. Runs last of our linkers since we want anything manual to link before this,
220
+ * and we do not want to break emails.
221
+ */
222
+ {
223
+ name: 'autolink',
224
+ process: (textToProcess, replacement) => {
225
+ const regex = new RegExp(`(?![^<]*>|[^<>]*<\\/(?!h1>))([_*~]*?)${UrlPatterns.MARKDOWN_URL_REGEX}\\1(?!((?:(?!<a).)+)?<\\/a>|[^<]*(<\\/pre>|<\\/code>|.+\\/>))`, 'gi');
226
+ return this.modifyTextForUrlLinks(regex, textToProcess, replacement);
227
+ },
228
+ replacement: (match, g1, g2) => {
229
+ const href = str_1.default.sanitizeURL(g2);
230
+ return `${g1}<a href="${href}" target="_blank" rel="noreferrer noopener">${g2}</a>${g1}`;
231
+ },
232
+ rawInputReplacement: (_match, g1, g2) => {
233
+ const href = str_1.default.sanitizeURL(g2);
234
+ return `${g1}<a href="${href}" data-raw-href="${g2}" data-link-variant="auto" target="_blank" rel="noreferrer noopener">${g2}</a>${g1}`;
235
+ },
236
+ },
237
+ {
238
+ name: 'quote',
239
+ // We also want to capture a blank line before or after the quote so that we do not add extra spaces.
240
+ // block quotes naturally appear on their own line. Blockquotes should not appear in code fences or
241
+ // inline code blocks. A single prepending space should be stripped if it exists
242
+ process: (textToProcess, replacement, shouldKeepRawInput = false) => {
243
+ const regex = /^(?:&gt;)+ +(?! )(?![^<]*(?:<\/pre>|<\/code>))([^\v\n\r]+)/gm;
244
+ const replaceFunction = (g1) => replacement(g1, shouldKeepRawInput);
245
+ if (shouldKeepRawInput) {
246
+ return textToProcess.replace(regex, replaceFunction);
247
+ }
248
+ return this.modifyTextForQuote(regex, textToProcess, replacement);
249
+ },
250
+ replacement: (g1, shouldKeepRawInput = false) => {
251
+ // We want to enable 2 options of nested heading inside the blockquote: "># heading" and "> # heading".
252
+ // To do this we need to parse body of the quote without first space
253
+ let isStartingWithSpace = false;
254
+ const handleMatch = (match, g2) => {
255
+ if (shouldKeepRawInput) {
256
+ isStartingWithSpace = !!g2;
257
+ return '';
258
+ }
259
+ return match;
260
+ };
261
+ const textToReplace = g1.replace(/^&gt;( )?/gm, handleMatch);
262
+ const filterRules = ['heading1'];
263
+ // if we don't reach the max quote depth we allow the recursive call to process possible quote
264
+ if (this.currentQuoteDepth < this.maxQuoteDepth - 1 || isStartingWithSpace) {
265
+ filterRules.push('quote');
266
+ this.currentQuoteDepth++;
267
+ }
268
+ const replacedText = this.replace(textToReplace, {
269
+ filterRules,
270
+ shouldEscapeText: false,
271
+ shouldKeepRawInput,
272
+ });
273
+ this.currentQuoteDepth = 0;
274
+ return `<blockquote>${isStartingWithSpace ? ' ' : ''}${replacedText}</blockquote>`;
275
+ },
276
+ },
277
+ {
278
+ /**
279
+ * Use \b in this case because it will match on words, letters,
280
+ * and _: https://www.rexegg.com/regex-boundaries.html#wordboundary
281
+ * The !_blank is to prevent the `target="_blank">` section of the
282
+ * link replacement from being captured Additionally, something like
283
+ * `\b\_([^<>]*?)\_\b` doesn't work because it won't replace
284
+ * `_https://www.test.com_`
285
+ * Use [\s\S]* instead of .* to match newline
286
+ */
287
+ name: 'italic',
288
+ regex: /(?<!<[^>]*)(\b_+|\b)(?!_blank")_((?![\s_])[\s\S]*?[^\s_](?<!\s))_(?![^\W_])(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>|<\/mention-user>|_blank))/g,
289
+ // We want to add extraLeadingUnderscores back before the <em> tag unless textWithinUnderscores starts with valid email
290
+ replacement: (match, extraLeadingUnderscores, textWithinUnderscores) => {
291
+ if (textWithinUnderscores.includes('</pre>') || this.containsNonPairTag(textWithinUnderscores)) {
292
+ return match;
293
+ }
294
+ if (String(textWithinUnderscores).match(`^${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}`)) {
295
+ return `<em>${extraLeadingUnderscores}${textWithinUnderscores}</em>`;
296
+ }
297
+ return `${extraLeadingUnderscores}<em>${textWithinUnderscores}</em>`;
298
+ },
299
+ },
300
+ /**
301
+ * Automatically links emails that are not in a link. Runs before the autolinker as it will not link an
302
+ * email that is in a link
303
+ * Prevent emails from starting with [~_*]. Such emails should not be supported.
304
+ */
305
+ {
306
+ name: 'autoEmail',
307
+ regex: new RegExp(`([^\\w'#%+-]|^)${Constants.CONST.REG_EXP.MARKDOWN_EMAIL}(?!((?:(?!<a).)+)?<\\/a>|[^<>]*<\\/(?!em|h1|blockquote))`, 'gim'),
308
+ replacement: '$1<a href="mailto:$2">$2</a>',
309
+ rawInputReplacement: '$1<a href="mailto:$2" data-raw-href="$2" data-link-variant="auto">$2</a>',
310
+ },
311
+ {
312
+ // Use \B in this case because \b doesn't match * or ~.
313
+ // \B will match everything that \b doesn't, so it works
314
+ // for * and ~: https://www.rexegg.com/regex-boundaries.html#notb
315
+ name: 'bold',
316
+ regex: /(?<!<[^>]*)\B\*(?![^<]*(?:<\/pre>|<\/code>|<\/a>))((?![\s*])[\s\S]*?[^\s*](?<!\s))\*\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>))/g,
317
+ replacement: (match, g1) => (g1.includes('</pre>') || this.containsNonPairTag(g1) ? match : `<strong>${g1}</strong>`),
318
+ },
319
+ {
320
+ name: 'strikethrough',
321
+ regex: /(?<!<[^>]*)\B~((?![\s~])[\s\S]*?[^\s~](?<!\s))~\B(?![^<]*>)(?![^<]*(<\/pre>|<\/code>|<\/a>))/g,
322
+ replacement: (match, g1) => (g1.includes('</pre>') || this.containsNonPairTag(g1) ? match : `<del>${g1}</del>`),
323
+ },
324
+ {
325
+ name: 'newline',
326
+ regex: /\r?\n/g,
327
+ replacement: '<br />',
328
+ },
329
+ {
330
+ // We're removing <br /> because when </pre> and <br /> occur together, an extra line is added.
331
+ name: 'replacepre',
332
+ regex: /<\/pre>\s*<br\s*[/]?>/gi,
333
+ replacement: '</pre>',
334
+ },
335
+ {
336
+ // We're removing <br /> because when <h1> and <br /> occur together, an extra line is added.
337
+ name: 'replaceh1br',
338
+ regex: /<\/h1><br\s*[/]?>/gi,
339
+ replacement: '</h1>',
340
+ },
341
+ ];
342
+ /**
343
+ * The list of regex replacements to do on a HTML comment for converting it to markdown.
344
+ * Order of rules is important
345
+ * @type {Object[]}
346
+ */
347
+ this.htmlToMarkdownRules = [
348
+ // Used to Exclude tags
349
+ {
350
+ name: 'replacepre',
351
+ regex: /<\/pre>(.)/gi,
352
+ replacement: '</pre><br />$1',
353
+ },
354
+ {
355
+ name: 'exclude',
356
+ regex: new RegExp(['<(script|style)(?:"[^"]*"|\'[^\']*\'|[^\'">])*>([\\s\\S]*?)<\\/\\1>', '(?![^<]*(<\\/pre>|<\\/code>))(\n|\r\n)?'].join(''), 'gim'),
357
+ replacement: '',
358
+ },
359
+ {
360
+ name: 'nested',
361
+ regex: /<(pre)(?:"[^"]*"|'[^']*'|[^'">])*><(div|code)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\2><\/pre>/gi,
362
+ replacement: '<pre>$3</pre>',
363
+ },
364
+ {
365
+ name: 'newline',
366
+ // Replaces open and closing <br><br/> tags with a single <br/>
367
+ // Slack uses special <span> tag for empty lines instead of <br> tag
368
+ pre: (inputString) => inputString
369
+ .replace('<br></br>', '<br/>')
370
+ .replace('<br><br/>', '<br/>')
371
+ .replace(/(<tr.*?<\/tr>)/g, '$1<br/>')
372
+ .replace('<br/></tbody>', '')
373
+ .replace(SLACK_SPAN_NEW_LINE_TAG + SLACK_SPAN_NEW_LINE_TAG, '<br/><br/><br/>')
374
+ .replace(SLACK_SPAN_NEW_LINE_TAG, '<br/><br/>'),
375
+ // Include the immediately followed newline as `<br>\n` should be equal to one \n.
376
+ regex: /<br(?:"[^"]*"|'[^']*'|[^'"><])*>\n?/gi,
377
+ replacement: '\n',
378
+ },
379
+ {
380
+ name: 'heading1',
381
+ regex: /[^\S\r\n]*<(h1)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
382
+ replacement: '<h1># $2</h1>',
383
+ },
384
+ {
385
+ name: 'listItem',
386
+ regex: /\s*<(li)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))\s*/gi,
387
+ replacement: '<li> $2</li>',
388
+ },
389
+ // Use [\s\S]* instead of .* to match newline
390
+ {
391
+ name: 'italic',
392
+ regex: /<(em|i)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
393
+ replacement: '_$2_',
394
+ },
395
+ {
396
+ name: 'bold',
397
+ regex: /<(b|strong)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
398
+ replacement: '*$2*',
399
+ },
400
+ {
401
+ name: 'strikethrough',
402
+ regex: /<(del|s)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
403
+ replacement: '~$2~',
404
+ },
405
+ {
406
+ name: 'quote',
407
+ regex: /<(blockquote|q)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
408
+ replacement: (match, g1, g2) => {
409
+ // We remove the line break before heading inside quote to avoid adding extra line
410
+ let resultString = g2
411
+ .replace(/\n?(<h1># )/g, '$1')
412
+ .replace(/(<h1>|<\/h1>)+/g, '\n')
413
+ .trim()
414
+ .split('\n');
415
+ const prependGreaterSign = (m) => `> ${m}`;
416
+ resultString = _.map(resultString, prependGreaterSign).join('\n');
417
+ // We want to keep <blockquote> tag here and let method replaceBlockElementWithNewLine to handle the line break later
418
+ return `<blockquote>${resultString}</blockquote>`;
419
+ },
420
+ },
421
+ {
422
+ name: 'inlineCodeBlock',
423
+ regex: /<(code)(?:"[^"]*"|'[^']*'|[^'">])*>(.*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
424
+ replacement: '`$2`',
425
+ },
426
+ {
427
+ name: 'codeFence',
428
+ regex: /<(pre)(?:"[^"]*"|'[^']*'|[^'">])*>([\s\S]*?)(\n?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
429
+ replacement: (match, g1, g2) => `\`\`\`\n${g2}\n\`\`\``,
430
+ },
431
+ {
432
+ name: 'anchor',
433
+ regex: /<(a)[^><]*href\s*=\s*(['"])(.*?)\2(?:".*?"|'.*?'|[^'"><])*>([\s\S]*?)<\/\1>(?![^<]*(<\/pre>|<\/code>))/gi,
434
+ replacement: (match, g1, g2, g3, g4) => {
435
+ const email = g3.startsWith('mailto:') ? g3.slice(7) : '';
436
+ if (email === g4) {
437
+ return email;
438
+ }
439
+ return `[${g4}](${email || g3})`;
440
+ },
441
+ },
442
+ {
443
+ name: 'image',
444
+ regex: /<img[^><]*src\s*=\s*(['"])(.*?)\1(?:[^><]*alt\s*=\s*(['"])(.*?)\3)?[^><]*>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi,
445
+ replacement: (match, g1, g2, g3, g4) => {
446
+ if (g4) {
447
+ return `![${g4}](${g2})`;
448
+ }
449
+ return `!(${g2})`;
450
+ },
451
+ },
452
+ {
453
+ name: 'reportMentions',
454
+ regex: /<mention-report reportID="(\d+)" *\/>/gi,
455
+ replacement: (match, g1, offset, string, extras) => {
456
+ const reportToNameMap = extras.reportIdToName;
457
+ if (!reportToNameMap || !reportToNameMap[g1]) {
458
+ Log_1.default.alert('[ExpensiMark] Missing report name', { reportID: g1 });
459
+ return '#Hidden';
460
+ }
461
+ return reportToNameMap[g1];
462
+ },
463
+ },
464
+ {
465
+ name: 'userMention',
466
+ regex: /(?:<mention-user accountID="(\d+)" *\/>)|(?:<mention-user>(.*?)<\/mention-user>)/gi,
467
+ replacement: (match, g1, g2, offset, string, extras) => {
468
+ if (g1) {
469
+ const accountToNameMap = extras.accountIdToName;
470
+ if (!accountToNameMap || !accountToNameMap[g1]) {
471
+ Log_1.default.alert('[ExpensiMark] Missing account name', { accountID: g1 });
472
+ return '@Hidden';
473
+ }
474
+ return `@${extras.accountIdToName[g1]}`;
475
+ }
476
+ return str_1.default.removeSMSDomain(g2);
477
+ },
478
+ },
479
+ ];
480
+ /**
481
+ * The list of rules to covert the HTML to text.
482
+ * Order of rules is important
483
+ * @type {Object[]}
484
+ */
485
+ this.htmlToTextRules = [
486
+ {
487
+ name: 'breakline',
488
+ regex: /<br[^>]*>/gi,
489
+ replacement: '\n',
490
+ },
491
+ {
492
+ name: 'blockquoteWrapHeadingOpen',
493
+ regex: /<blockquote><h1>/gi,
494
+ replacement: '<blockquote>',
495
+ },
496
+ {
497
+ name: 'blockquoteWrapHeadingClose',
498
+ regex: /<\/h1><\/blockquote>/gi,
499
+ replacement: '</blockquote>',
500
+ },
501
+ {
502
+ name: 'blockElementOpen',
503
+ regex: /(.|\s)<(blockquote|h1|pre)>/gi,
504
+ replacement: '$1\n',
505
+ },
506
+ {
507
+ name: 'blockElementClose',
508
+ regex: /<\/(blockquote|h1|pre)>(.|\s)/gm,
509
+ replacement: '\n$2',
510
+ },
511
+ {
512
+ name: 'removeStyle',
513
+ regex: /<style>.*?<\/style>/gi,
514
+ replacement: '',
515
+ },
516
+ {
517
+ name: 'image',
518
+ regex: /<img[^><]*src\s*=\s*(['"])(.*?)\1(?:[^><]*alt\s*=\s*(['"])(.*?)\3)?[^><]*>*(?![^<][\s\S]*?(<\/pre>|<\/code>))/gi,
519
+ replacement: '[Attachment]',
520
+ },
521
+ {
522
+ name: 'reportMentions',
523
+ regex: /<mention-report reportID="(\d+)" *\/>/gi,
524
+ replacement: (match, g1, offset, string, extras) => {
525
+ const reportToNameMap = extras.reportIdToName;
526
+ if (!reportToNameMap || !reportToNameMap[g1]) {
527
+ Log_1.default.alert('[ExpensiMark] Missing report name', { reportID: g1 });
528
+ return '#Hidden';
529
+ }
530
+ return reportToNameMap[g1];
531
+ },
532
+ },
533
+ {
534
+ name: 'userMention',
535
+ regex: /<mention-user accountID="(\d+)" *\/>/gi,
536
+ replacement: (match, g1, offset, string, extras) => {
537
+ const accountToNameMap = extras.accountIdToName;
538
+ if (!accountToNameMap || !accountToNameMap[g1]) {
539
+ Log_1.default.alert('[ExpensiMark] Missing account name', { accountID: g1 });
540
+ return '@Hidden';
541
+ }
542
+ return `@${extras.accountIdToName[g1]}`;
543
+ },
544
+ },
545
+ {
546
+ name: 'stripTag',
547
+ regex: /(<([^>]+)>)/gi,
548
+ replacement: '',
549
+ },
550
+ ];
551
+ /**
552
+ * The list of rules that we have to exclude in shouldKeepWhitespaceRules list.
553
+ * @type {Object[]}
554
+ */
555
+ this.whitespaceRulesToDisable = ['newline', 'replacepre', 'replacebr', 'replaceh1br'];
556
+ /**
557
+ * The list of rules that have to be applied when shouldKeepWhitespace flag is true.
558
+ * @param {Object} rule - The rule to check.
559
+ * @returns {boolean} Returns true if the rule should be applied, otherwise false.
560
+ */
561
+ this.filterRules = (rule) => !_.includes(this.whitespaceRulesToDisable, rule.name);
562
+ /**
563
+ * Filters rules to determine which should keep whitespace.
564
+ * @returns {Object[]} The filtered rules.
565
+ */
566
+ this.shouldKeepWhitespaceRules = _.filter(this.rules, this.filterRules);
567
+ /**
568
+ * maxQuoteDepth is the maximum depth of nested quotes that we want to support.
569
+ * @type {Number}
570
+ */
571
+ this.maxQuoteDepth = 3;
572
+ /**
573
+ * currentQuoteDepth is the current depth of nested quotes that we are processing.
574
+ * @type {Number}
575
+ */
576
+ this.currentQuoteDepth = 0;
577
+ }
578
+ getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput) {
579
+ let rules = this.rules;
580
+ const hasRuleName = (rule) => _.contains(filterRules, rule.name);
581
+ const hasDisabledRuleName = (rule) => !_.contains(disabledRules, rule.name);
582
+ if (shouldKeepRawInput) {
583
+ rules = this.shouldKeepWhitespaceRules;
584
+ }
585
+ if (!_.isEmpty(filterRules)) {
586
+ rules = _.filter(this.rules, hasRuleName);
587
+ }
588
+ if (!_.isEmpty(disabledRules)) {
589
+ rules = _.filter(rules, hasDisabledRuleName);
590
+ }
591
+ return rules;
592
+ }
593
+ /**
594
+ * Replaces markdown with html elements
595
+ *
596
+ * @param {String} text - Text to parse as markdown
597
+ * @param {Object} [options] - Options to customize the markdown parser
598
+ * @param {String[]} [options.filterRules=[]] - An array of name of rules as defined in this class.
599
+ * If not provided, all available rules will be applied.
600
+ * @param {Boolean} [options.shouldEscapeText=true] - Whether or not the text should be escaped
601
+ * @param {String[]} [options.disabledRules=[]] - An array of name of rules as defined in this class.
602
+ * If not provided, all available rules will be applied. If provided, the rules in the array will be skipped.
603
+ *
604
+ * @returns {String}
605
+ */
606
+ replace(text, { filterRules = [], shouldEscapeText = true, shouldKeepRawInput = false, disabledRules = [] } = {}) {
607
+ // This ensures that any html the user puts into the comment field shows as raw html
608
+ let replacedText = shouldEscapeText ? _.escape(text) : text;
609
+ const rules = this.getHtmlRuleset(filterRules, disabledRules, shouldKeepRawInput);
610
+ const processRule = (rule) => {
611
+ // Pre-process text before applying regex
612
+ if (rule.pre) {
613
+ replacedText = rule.pre(replacedText);
614
+ }
615
+ const replacementFunction = shouldKeepRawInput && rule.rawInputReplacement ? rule.rawInputReplacement : rule.replacement;
616
+ if (rule.process) {
617
+ replacedText = rule.process(replacedText, replacementFunction, shouldKeepRawInput);
618
+ }
619
+ else {
620
+ replacedText = replacedText.replace(rule.regex, replacementFunction);
621
+ }
622
+ // Post-process text after applying regex
623
+ if (rule.post) {
624
+ replacedText = rule.post(replacedText);
625
+ }
626
+ };
627
+ try {
628
+ rules.forEach(processRule);
629
+ }
630
+ catch (e) {
631
+ // eslint-disable-next-line no-console
632
+ console.warn('Error replacing text with html in ExpensiMark.replace', { error: e });
633
+ // We want to return text without applying rules if exception occurs during replacing
634
+ return shouldEscapeText ? _.escape(text) : text;
635
+ }
636
+ return replacedText;
637
+ }
638
+ /**
639
+ * Checks matched URLs for validity and replace valid links with html elements
640
+ *
641
+ * @param {RegExp} regex
642
+ * @param {String} textToCheck
643
+ * @param {Function} replacement
644
+ *
645
+ * @returns {String}
646
+ */
647
+ modifyTextForUrlLinks(regex, textToCheck, replacement) {
648
+ let match = regex.exec(textToCheck);
649
+ let replacedText = '';
650
+ let startIndex = 0;
651
+ while (match !== null) {
652
+ // 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
653
+ // and can be part of markdown for example
654
+ let unmatchedOpenParentheses = 0;
655
+ let url = match[2];
656
+ for (let i = 0; i < url.length; i++) {
657
+ if (url[i] === '(') {
658
+ unmatchedOpenParentheses++;
659
+ }
660
+ else if (url[i] === ')') {
661
+ // Unmatched closing parenthesis
662
+ if (unmatchedOpenParentheses <= 0) {
663
+ const numberOfCharsToRemove = url.length - i;
664
+ match[0] = match[0].substr(0, match[0].length - numberOfCharsToRemove);
665
+ url = url.substr(0, url.length - numberOfCharsToRemove);
666
+ break;
667
+ }
668
+ unmatchedOpenParentheses--;
669
+ }
670
+ }
671
+ // Because we are removing ) parenthesis, some special characters that shouldn't be in the href are in the href
672
+ // 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
673
+ // In that case we should also remove the "."
674
+ // Those characters should only be remove from the url if this url doesn't have a parameter or a fragment
675
+ if (!url.includes('?') && !url.includes('#')) {
676
+ let numberOfCharsToRemove = 0;
677
+ for (let i = url.length - 1; i >= 0; i--) {
678
+ if (Constants.CONST.SPECIAL_CHARS_TO_REMOVE.includes(url[i])) {
679
+ numberOfCharsToRemove++;
680
+ }
681
+ else {
682
+ break;
683
+ }
684
+ }
685
+ if (numberOfCharsToRemove) {
686
+ match[0] = match[0].substring(0, match[0].length - numberOfCharsToRemove);
687
+ url = url.substring(0, url.length - numberOfCharsToRemove);
688
+ }
689
+ }
690
+ replacedText = replacedText.concat(textToCheck.substr(startIndex, match.index - startIndex));
691
+ // We want to avoid matching domains in email addresses so we don't render them as URLs,
692
+ // but we need to check if there are valid URLs after the email address and render them accordingly,
693
+ // e.g. test@expensify.com/https://www.test.com
694
+ let isDoneMatching = false;
695
+ let shouldApplyAutoLinkAgain = true;
696
+ // If we find a URL with a leading @ sign, we need look for other domains in the rest of the string
697
+ if (match.index !== 0 && textToCheck[match.index - 1] === '@') {
698
+ const domainRegex = /^(([a-z-0-9]+\.)+[a-z]{2,})(\S*)/i;
699
+ const domainMatch = domainRegex.exec(url);
700
+ // 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.
701
+ if (domainMatch !== null && domainMatch[3] !== '') {
702
+ replacedText = replacedText.concat(domainMatch[1] + this.replace(domainMatch[3], { filterRules: ['autolink'] }));
703
+ shouldApplyAutoLinkAgain = false;
704
+ }
705
+ else {
706
+ // Otherwise, we're done applying rules
707
+ isDoneMatching = true;
708
+ }
709
+ }
710
+ // 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)
711
+ // or if match[1] is multiline text preceeded by markdown heading, e.g., # [example\nexample\nexample](https://example.com)
712
+ if (isDoneMatching || match[1].includes('</pre>') || match[1].includes('</h1>')) {
713
+ replacedText = replacedText.concat(textToCheck.substr(match.index, match[0].length));
714
+ }
715
+ else if (shouldApplyAutoLinkAgain) {
716
+ const urlRegex = new RegExp(`^${UrlPatterns.LOOSE_URL_REGEX}$|^${UrlPatterns.URL_REGEX}$`, 'i');
717
+ // `match[1]` contains the text inside the [] of the markdown e.g. [example](https://example.com)
718
+ // At the entry of function this.replace, text is already escaped due to the rules that precede the link
719
+ // rule (eg, codeFence, inlineCodeBlock, email), so we don't need to escape the text again here.
720
+ // If the text `match[1]` exactly matches a URL, we skip translating the filterRules
721
+ // So that special characters such as ['_', '*', '~'] are preserved in the text.
722
+ const linkText = urlRegex.test(match[1])
723
+ ? match[1]
724
+ : this.replace(match[1], {
725
+ filterRules: ['bold', 'strikethrough', 'italic'],
726
+ shouldEscapeText: false,
727
+ });
728
+ replacedText = replacedText.concat(replacement(match[0], linkText, url));
729
+ }
730
+ startIndex = match.index + match[0].length;
731
+ // Now we move to the next match that the js regex found in the text
732
+ match = regex.exec(textToCheck);
733
+ }
734
+ if (startIndex < textToCheck.length) {
735
+ replacedText = replacedText.concat(textToCheck.substr(startIndex));
736
+ }
737
+ return replacedText;
738
+ }
739
+ /**
740
+ * Checks matched Emails for validity and replace valid links with html elements
741
+ *
742
+ * @param {RegExp} regex
743
+ * @param {String} textToCheck
744
+ * @param {Function} replacement
745
+ * @param {Boolean} shouldKeepRawInput
746
+ *
747
+ * @returns {String}
748
+ */
749
+ modifyTextForEmailLinks(regex, textToCheck, replacement, shouldKeepRawInput) {
750
+ let match = regex.exec(textToCheck);
751
+ let replacedText = '';
752
+ let startIndex = 0;
753
+ while (match !== null) {
754
+ replacedText = replacedText.concat(textToCheck.substr(startIndex, match.index - startIndex));
755
+ // `match[1]` contains the text inside []. Text is already escaped due to the rules
756
+ // that precede the link rule (eg, codeFence, inlineCodeBlock, email).
757
+ const linkText = this.replace(match[1], {
758
+ filterRules: ['bold', 'strikethrough', 'italic'],
759
+ shouldEscapeText: false,
760
+ });
761
+ // rawInputReplacment needs to be called with additional parameters from match
762
+ const replacedMatch = shouldKeepRawInput ? replacement(match[0], linkText, match[2], match[3]) : replacement(match[0], linkText, match[3]);
763
+ replacedText = replacedText.concat(replacedMatch);
764
+ startIndex = match.index + match[0].length;
765
+ // Now we move to the next match that the js regex found in the text
766
+ match = regex.exec(textToCheck);
767
+ }
768
+ if (startIndex < textToCheck.length) {
769
+ replacedText = replacedText.concat(textToCheck.substr(startIndex));
770
+ }
771
+ return replacedText;
772
+ }
773
+ /**
774
+ * replace block element with '\n' if :
775
+ * 1. We have text within the element.
776
+ * 2. The text does not end with a new line.
777
+ * 3. The text does not have quote mark '>' .
778
+ * 4. It's not the last element in the string.
779
+ *
780
+ * @param {String} htmlString
781
+ * @returns {String}
782
+ */
783
+ replaceBlockElementWithNewLine(htmlString) {
784
+ // eslint-disable-next-line max-len
785
+ let splitText = htmlString.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>/);
786
+ const stripHTML = (text) => str_1.default.stripHTML(text);
787
+ splitText = _.map(splitText, stripHTML);
788
+ let joinedText = '';
789
+ // Delete whitespace at the end
790
+ while (splitText.length) {
791
+ if (splitText[splitText.length - 1].trim().length > 0 || splitText[splitText.length - 1].match(/\n/)) {
792
+ break;
793
+ }
794
+ splitText.pop();
795
+ }
796
+ const processText = (text, index) => {
797
+ if (text.trim().length === 0 && !text.match(/\n/)) {
798
+ return;
799
+ }
800
+ // Insert '\n' unless it ends with '\n' or '>' or it's the last element.
801
+ if (text.match(/[\n|>][>]?[\s]?$/) || index === splitText.length - 1) {
802
+ joinedText += text;
803
+ }
804
+ else {
805
+ joinedText += `${text}\n`;
806
+ }
807
+ };
808
+ splitText.forEach(processText);
809
+ return joinedText;
810
+ }
811
+ /**
812
+ * Replaces HTML with markdown
813
+ *
814
+ * @param {String} htmlString
815
+ * @param {Object} extras
816
+ *
817
+ * @returns {String}
818
+ */
819
+ htmlToMarkdown(htmlString, extras = {}) {
820
+ let generatedMarkdown = htmlString;
821
+ const body = /<(body)(?:"[^"]*"|'[^']*'|[^'"><])*>(?:\n|\r\n)?([\s\S]*?)(?:\n|\r\n)?<\/\1>(?![^<]*(<\/pre>|<\/code>))/im;
822
+ const parseBodyTag = generatedMarkdown.match(body);
823
+ // If body tag is found then use the content of body rather than the whole HTML
824
+ if (parseBodyTag) {
825
+ generatedMarkdown = parseBodyTag[2];
826
+ }
827
+ const processRule = (rule) => {
828
+ // Pre-processes input HTML before applying regex
829
+ if (rule.pre) {
830
+ generatedMarkdown = rule.pre(generatedMarkdown);
831
+ }
832
+ // if replacement is a function, we want to pass optional extras to it
833
+ const replacementFunction = typeof rule.replacement === 'function' ? (...args) => rule.replacement(...args, extras) : rule.replacement;
834
+ generatedMarkdown = generatedMarkdown.replace(rule.regex, replacementFunction);
835
+ };
836
+ this.htmlToMarkdownRules.forEach(processRule);
837
+ return str_1.default.htmlDecode(this.replaceBlockElementWithNewLine(generatedMarkdown));
838
+ }
839
+ /**
840
+ * Convert HTML to text
841
+ *
842
+ * @param {String} htmlString
843
+ * @param {Object} extras
844
+ *
845
+ * @returns {String}
846
+ */
847
+ htmlToText(htmlString, extras = {}) {
848
+ let replacedText = htmlString;
849
+ const processRule = (rule) => {
850
+ // if replacement is a function, we want to pass optional extras to it
851
+ const replacementFunction = typeof rule.replacement === 'function' ? (...args) => rule.replacement(...args, extras) : rule.replacement;
852
+ replacedText = replacedText.replace(rule.regex, replacementFunction);
853
+ };
854
+ this.htmlToTextRules.forEach(processRule);
855
+ // Unescaping because the text is escaped in 'replace' function
856
+ // We use 'htmlDecode' instead of 'unescape' to replace entities like '&#32;'
857
+ replacedText = str_1.default.htmlDecode(replacedText);
858
+ return replacedText;
859
+ }
860
+ /**
861
+ * Modify text for Quotes replacing chevrons with html elements
862
+ *
863
+ * @param {RegExp} regex
864
+ * @param {String} textToCheck
865
+ * @param {Function} replacement
866
+ *
867
+ * @returns {String}
868
+ */
869
+ modifyTextForQuote(regex, textToCheck, replacement) {
870
+ let replacedText = '';
871
+ let textToFormat = '';
872
+ const match = textToCheck.match(regex);
873
+ // If there's matches we need to modify the quotes
874
+ if (match !== null) {
875
+ let insideCodefence = false;
876
+ // Split the textToCheck in lines
877
+ const textSplitted = textToCheck.split('\n');
878
+ for (let i = 0; i < textSplitted.length; i++) {
879
+ if (!insideCodefence) {
880
+ // We need to know when there is a start of codefence so we dont quote
881
+ insideCodefence = str_1.default.contains(textSplitted[i], '<pre>');
882
+ }
883
+ // We only want to modify lines starting with &gt; that is not codefence
884
+ if (str_1.default.startsWith(textSplitted[i], '&gt;') && !insideCodefence) {
885
+ textToFormat += `${textSplitted[i]}\n`;
886
+ }
887
+ else {
888
+ // Make sure we will only modify if we have Text needed to be formatted for quote
889
+ if (textToFormat !== '') {
890
+ replacedText += this.formatTextForQuote(regex, textToFormat, replacement);
891
+ textToFormat = '';
892
+ }
893
+ // We dont want a \n after the textSplitted if it is the last row
894
+ if (i === textSplitted.length - 1) {
895
+ replacedText += `${textSplitted[i]}`;
896
+ }
897
+ else {
898
+ replacedText += `${textSplitted[i]}\n`;
899
+ }
900
+ // We need to know when we are not inside codefence anymore
901
+ if (insideCodefence) {
902
+ insideCodefence = !str_1.default.contains(textSplitted[i], '</pre>');
903
+ }
904
+ }
905
+ }
906
+ // When loop ends we need the last quote to be formatted if we have quotes at last rows
907
+ if (textToFormat !== '') {
908
+ replacedText += this.formatTextForQuote(regex, textToFormat, replacement);
909
+ }
910
+ }
911
+ else {
912
+ // If we doesn't have matches make sure the function will return the same textToCheck
913
+ replacedText = textToCheck;
914
+ }
915
+ return replacedText;
916
+ }
917
+ /**
918
+ * Format the content of blockquote if the text matches the regex or else just return the original text
919
+ *
920
+ * @param {RegExp} regex
921
+ * @param {String} textToCheck
922
+ * @param {Function} replacement
923
+ *
924
+ * @returns {String}
925
+ */
926
+ formatTextForQuote(regex, textToCheck, replacement) {
927
+ if (textToCheck.match(regex)) {
928
+ // Remove '&gt;' and trim the spaces between nested quotes
929
+ const formatRow = (row) => {
930
+ const quoteContent = row[4] === ' ' ? row.substr(5) : row.substr(4);
931
+ if (quoteContent.trimStart().startsWith('&gt;')) {
932
+ return quoteContent.trimStart();
933
+ }
934
+ return quoteContent;
935
+ };
936
+ let textToFormat = _.map(textToCheck.split('\n'), formatRow).join('\n');
937
+ // Remove leading and trailing line breaks
938
+ textToFormat = textToFormat.replace(/^\n+|\n+$/g, '');
939
+ return replacement(textToFormat);
940
+ }
941
+ return textToCheck;
942
+ }
943
+ /**
944
+ * Check if the input text includes only the open or the close tag of an element.
945
+ *
946
+ * @param {String} textToCheck - Text to check
947
+ *
948
+ * @returns {Boolean}
949
+ */
950
+ containsNonPairTag(textToCheck) {
951
+ // Create a regular expression to match HTML tags
952
+ const tagRegExp = /<([a-z][a-z0-9-]*)\b[^>]*>|<\/([a-z][a-z0-9-]*)\s*>/gi;
953
+ // Use a stack to keep track of opening tags
954
+ const tagStack = [];
955
+ // Match all HTML tags in the string
956
+ let match = tagRegExp.exec(textToCheck);
957
+ while (match) {
958
+ const openingTag = match[1];
959
+ const closingTag = match[2];
960
+ if (openingTag && openingTag !== 'br') {
961
+ // If it's an opening tag, push it onto the stack
962
+ tagStack.push(openingTag);
963
+ }
964
+ else if (closingTag) {
965
+ // If it's a closing tag, pop the top of the stack
966
+ const expectedTag = tagStack.pop();
967
+ // If the closing tag doesn't match the expected opening tag, return false
968
+ if (closingTag !== expectedTag) {
969
+ return true;
970
+ }
971
+ }
972
+ match = tagRegExp.exec(textToCheck);
973
+ }
974
+ // If there are any tags left in the stack, they're unclosed
975
+ return tagStack.length !== 0;
976
+ }
977
+ /**
978
+ * @param {String} comment
979
+ * @returns {Array} or undefined if exception occurs when executing regex matching
980
+ */
981
+ extractLinksInMarkdownComment(comment) {
982
+ try {
983
+ const htmlString = this.replace(comment, { filterRules: ['link'] });
984
+ // We use same anchor tag template as link and autolink rules to extract link
985
+ const regex = new RegExp(`<a href="${UrlPatterns.MARKDOWN_URL_REGEX}" target="_blank" rel="noreferrer noopener">`, 'gi');
986
+ const matches = [...htmlString.matchAll(regex)];
987
+ // Element 1 from match is the regex group if it exists which contains the link URLs
988
+ const sanitizeMatch = (match) => str_1.default.sanitizeURL(match[1]);
989
+ const links = _.map(matches, sanitizeMatch);
990
+ return links;
991
+ }
992
+ catch (e) {
993
+ // eslint-disable-next-line no-console
994
+ console.warn('Error parsing url in ExpensiMark.extractLinksInMarkdownComment', { error: e });
995
+ return undefined;
996
+ }
997
+ }
998
+ /**
999
+ * Compares two markdown comments and returns a list of the links removed in a new comment.
1000
+ *
1001
+ * @param {String} oldComment
1002
+ * @param {String} newComment
1003
+ * @returns {Array}
1004
+ */
1005
+ getRemovedMarkdownLinks(oldComment, newComment) {
1006
+ const linksInOld = this.extractLinksInMarkdownComment(oldComment);
1007
+ const linksInNew = this.extractLinksInMarkdownComment(newComment);
1008
+ return linksInOld === undefined || linksInNew === undefined ? [] : _.difference(linksInOld, linksInNew);
1009
+ }
1010
+ /**
1011
+ * Escapes the content of an HTML attribute value
1012
+ * @param {String} content - string content that possible contains HTML
1013
+ * @returns {String} - original MD content escaped for use in HTML attribute value
1014
+ */
1015
+ escapeAttributeContent(content) {
1016
+ let originalContent = this.htmlToMarkdown(content);
1017
+ if (content === originalContent) {
1018
+ return content;
1019
+ }
1020
+ // When the attribute contains HTML and is converted back to MD we need to re-escape it to avoid
1021
+ // illegal attribute value characters like `," or ' which might break the HTML
1022
+ originalContent = str_1.default.replaceAll(originalContent, '\n', '');
1023
+ return _.escape(originalContent);
1024
+ }
1025
+ }
1026
+ exports.default = ExpensiMark;