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