expensify-common 2.0.200 → 2.0.202

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