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.
- package/README.md +15 -6
- package/dist/API.d.ts +11 -0
- package/{lib/API.jsx → dist/API.js} +230 -165
- package/dist/APIDeferred.d.ts +7 -0
- package/{lib/APIDeferred.jsx → dist/APIDeferred.js} +35 -53
- package/dist/BrowserDetect.d.ts +19 -0
- package/dist/BrowserDetect.js +107 -0
- package/dist/CONST.d.ts +813 -0
- package/{lib/CONST.jsx → dist/CONST.js} +245 -167
- package/dist/Cookie.d.ts +68 -0
- package/{lib/Cookie.jsx → dist/Cookie.js} +23 -36
- package/dist/CredentialsWrapper.d.ts +32 -0
- package/dist/CredentialsWrapper.js +52 -0
- package/dist/Device.d.ts +8 -0
- package/dist/Device.js +15 -0
- package/dist/ExpenseRule.d.ts +39 -0
- package/{lib/ExpenseRule.jsx → dist/ExpenseRule.js} +12 -14
- package/dist/ExpensiMark.d.ts +142 -0
- package/dist/ExpensiMark.js +1026 -0
- package/dist/Func.d.ts +40 -0
- package/{lib/Func.jsx → dist/Func.js} +19 -25
- package/dist/Log.d.ts +3 -0
- package/dist/Log.js +41 -0
- package/dist/Logger.d.ts +77 -0
- package/dist/Logger.js +126 -0
- package/dist/Network.d.ts +6 -0
- package/{lib/Network.jsx → dist/Network.js} +48 -45
- package/dist/Num.d.ts +95 -0
- package/{lib/Num.jsx → dist/Num.js} +20 -40
- package/dist/PageEvent.d.ts +25 -0
- package/dist/PageEvent.js +28 -0
- package/dist/PubSub.d.ts +2 -0
- package/{lib/PubSub.jsx → dist/PubSub.js} +27 -39
- package/dist/ReportHistoryStore.d.ts +64 -0
- package/dist/ReportHistoryStore.js +261 -0
- package/dist/Templates.d.ts +2 -0
- package/{lib/Templates.jsx → dist/Templates.js} +33 -48
- package/dist/Url.d.ts +22 -0
- package/dist/Url.js +30 -0
- package/dist/components/CopyText.d.ts +45 -0
- package/{lib/components/CopyText.jsx → dist/components/CopyText.js} +16 -23
- package/dist/components/StepProgressBar.d.ts +22 -0
- package/dist/components/StepProgressBar.js +68 -0
- package/dist/components/form/element/combobox.d.ts +237 -0
- package/{lib → dist}/components/form/element/combobox.js +361 -516
- package/dist/components/form/element/dropdown.d.ts +35 -0
- package/dist/components/form/element/dropdown.js +66 -0
- package/dist/components/form/element/dropdownItem.d.ts +55 -0
- package/dist/components/form/element/dropdownItem.js +118 -0
- package/dist/components/form/element/onOffSwitch.d.ts +94 -0
- package/dist/components/form/element/onOffSwitch.js +195 -0
- package/dist/components/form/element/switch.d.ts +58 -0
- package/{lib → dist}/components/form/element/switch.js +29 -66
- package/dist/fastMerge.d.ts +9 -0
- package/dist/fastMerge.js +77 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +71 -0
- package/dist/jquery.expensifyIframify.d.ts +10 -0
- package/{lib → dist}/jquery.expensifyIframify.js +52 -93
- package/dist/mixins/PubSub.d.ts +20 -0
- package/{lib/mixins/PubSub.jsx → dist/mixins/PubSub.js} +12 -11
- package/dist/mixins/extraClasses.d.ts +8 -0
- package/{lib → dist}/mixins/extraClasses.js +8 -12
- package/dist/mixins/validationClasses.d.ts +12 -0
- package/dist/mixins/validationClasses.js +58 -0
- package/dist/str.d.ts +613 -0
- package/{lib → dist}/str.js +176 -160
- package/dist/tlds.d.ts +2 -0
- package/dist/tlds.js +4 -0
- package/dist/utils.d.ts +5 -0
- package/dist/utils.js +13 -0
- package/package.json +47 -18
- package/.editorconfig +0 -34
- package/.eslintrc.js +0 -11
- package/.github/CODEOWNERS +0 -2
- package/.github/CONTRIBUTING.md +0 -163
- package/.github/ISSUE_TEMPLATE.md +0 -3
- package/.github/PULL_REQUEST_TEMPLATE.md +0 -14
- package/.github/workflows/cla.yml +0 -39
- package/.github/workflows/lint.yml +0 -29
- package/.github/workflows/test.yml +0 -29
- package/CLA.md +0 -20
- package/Gruntfile.js +0 -13
- package/__tests__/ExpensiMark-test.js +0 -340
- package/__tests__/Logger-test.js +0 -55
- package/__tests__/Str-test.js +0 -53
- package/babel.config.js +0 -12
- package/grunt/configloader.js +0 -17
- package/grunt/configs/chokidar.js +0 -23
- package/grunt/configs/eslint.js +0 -15
- package/grunt/task/watch.js +0 -3
- package/grunt/taskloader.js +0 -25
- package/lib/BrowserDetect.jsx +0 -91
- package/lib/ExpensiMark.js +0 -253
- package/lib/Log.jsx +0 -36
- package/lib/Logger.jsx +0 -154
- package/lib/PageEvent.jsx +0 -23
- package/lib/ReportHistoryStore.jsx +0 -194
- package/lib/components/StepProgressBar.js +0 -49
- package/lib/components/form/element/dropdown.js +0 -90
- package/lib/components/form/element/dropdownItem.js +0 -178
- package/lib/components/form/element/onOffSwitch.jsx +0 -229
- package/lib/mixins/validationClasses.js +0 -23
- 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
|
+
// ` is a backtick symbol we are matching on three of them before then after a new line character
|
|
59
|
+
regex: /(```(?:\r\n|\n))((?:\s*?(?!(?:\r\n|\n)?```(?!`))[\S])+\s*?(?:\r\n|\n))(```)/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
|
+
// will create styling issues so use  
|
|
66
|
+
replacement: (match, __, textWithinFences) => {
|
|
67
|
+
const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, ' ');
|
|
68
|
+
return `<pre>${group}</pre>`;
|
|
69
|
+
},
|
|
70
|
+
rawInputReplacement: (match, __, textWithinFences) => {
|
|
71
|
+
const withinFences = match.replace(/(?:```)([\s\S]*?)(?:```)/g, '$1');
|
|
72
|
+
const group = textWithinFences.replace(/(?:(?![\n\r])\s)/g, ' ');
|
|
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|_|)`(.*?(?![`])\S.*?)`(\B|_|)(?!`|[^<]*<\/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. 
|
|
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 = /^(?:>)+ +(?! )(?![^<]*(?:<\/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(/^>( )?/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 ``;
|
|
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 ' '
|
|
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 > that is not codefence
|
|
884
|
+
if (str_1.default.startsWith(textSplitted[i], '>') && !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 '>' 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('>')) {
|
|
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;
|