expensify-common 2.0.42 → 2.0.44

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
@@ -325,12 +325,6 @@ declare const CONST: {
325
325
  * @type String
326
326
  */
327
327
  readonly MARKDOWN_EMAIL: "(?=((?=[\\w'#%+-]+(?:\\.[\\w'#%+-]+)*@)[\\w\\.'#%+-]{1,64}@(?:(?=[a-z\\d]+(?:-+[a-z\\d]+)*\\.)(?:[a-z\\d-]{1,63}\\.)+[a-z]{2,63})(?= |_|\\b))(?<end>.*))\\S{3,254}(?=\\k<end>$)";
328
- /**
329
- * Regex matching an text containing an Emoji
330
- *
331
- * @type RegExp
332
- */
333
- readonly EMOJIS: RegExp;
334
328
  /**
335
329
  * Regex matching an text containing an Emoji that can be a single emoji or made up by some different emojis
336
330
  *
package/dist/CONST.js CHANGED
@@ -333,13 +333,6 @@ const CONST = {
333
333
  * @type String
334
334
  */
335
335
  MARKDOWN_EMAIL: EMAIL_BASE_REGEX,
336
- /**
337
- * Regex matching an text containing an Emoji
338
- *
339
- * @type RegExp
340
- */
341
- // eslint-disable-next-line no-misleading-character-class
342
- EMOJIS: /[\p{Extended_Pictographic}\u200d\u{1f1e6}-\u{1f1ff}\u{1f3fb}-\u{1f3ff}\u{e0020}-\u{e007f}\u20E3\uFE0F]|[#*0-9]\uFE0F?\u20E3/gu,
343
336
  /**
344
337
  * Regex matching an text containing an Emoji that can be a single emoji or made up by some different emojis
345
338
  *
@@ -29,6 +29,12 @@ type ReplaceOptions = {
29
29
  shouldEscapeText?: boolean;
30
30
  shouldKeepRawInput?: boolean;
31
31
  };
32
+ type TruncateOptions = {
33
+ ellipsis?: string;
34
+ truncateLastWord?: boolean;
35
+ slop?: number;
36
+ removeImageTag?: boolean;
37
+ };
32
38
  export default class ExpensiMark {
33
39
  static Log: Logger;
34
40
  /**
@@ -138,6 +144,27 @@ export default class ExpensiMark {
138
144
  * @returns original MD content escaped for use in HTML attribute value
139
145
  */
140
146
  escapeAttributeContent(content: string): string;
147
+ /**
148
+ * Determines the end position to truncate the HTML content while considering word boundaries.
149
+ *
150
+ * @param {string} content - The HTML content to be truncated.
151
+ * @param {number} tailPosition - The position up to which the content should be considered.
152
+ * @param {number} maxLength - The maximum length of the truncated content.
153
+ * @param {number} totalLength - The length of the content processed so far.
154
+ * @param {Object} opts - Options to customize the truncation.
155
+ * @returns {number} The calculated position to truncate the content.
156
+ */
157
+ getEndPosition(content: string, tailPosition: number | undefined, maxLength: number, totalLength: number, opts: TruncateOptions): number;
158
+ /**
159
+ * Truncate HTML string and keep tag safe.
160
+ * pulled from https://github.com/huang47/nodejs-html-truncate/blob/master/lib/truncate.js
161
+ *
162
+ * @param {string} html - The string that needs to be truncated
163
+ * @param {number} maxLength - Length of truncated string
164
+ * @param {Object} [options] - Optional configuration options
165
+ * @returns {string} The truncated string
166
+ */
167
+ truncateHTML(html: string, maxLength: number, options?: TruncateOptions): string;
141
168
  /**
142
169
  * Replaces text with a replacement based on a regex
143
170
  * @param text - The text to replace
@@ -107,7 +107,7 @@ class ExpensiMark {
107
107
  return this.modifyTextForEmailLinks(regex, textToProcess, replacement, shouldKeepRawInput);
108
108
  },
109
109
  replacement: (_extras, match, g1, g2) => {
110
- if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
110
+ if (!g1.trim()) {
111
111
  return match;
112
112
  }
113
113
  const label = g1.trim();
@@ -116,7 +116,7 @@ class ExpensiMark {
116
116
  return `<a href="${href}">${formattedLabel}</a>`;
117
117
  },
118
118
  rawInputReplacement: (_extras, match, g1, g2, g3) => {
119
- if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
119
+ if (!g1.trim()) {
120
120
  return match;
121
121
  }
122
122
  const dataRawHref = g2 ? g2 + g3 : g3;
@@ -177,13 +177,13 @@ class ExpensiMark {
177
177
  name: 'link',
178
178
  process: (textToProcess, replacement) => this.modifyTextForUrlLinks(MARKDOWN_LINK_REGEX, textToProcess, replacement),
179
179
  replacement: (_extras, match, g1, g2) => {
180
- if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
180
+ if (!g1.trim()) {
181
181
  return match;
182
182
  }
183
183
  return `<a href="${str_1.default.sanitizeURL(g2)}" target="_blank" rel="noreferrer noopener">${g1.trim()}</a>`;
184
184
  },
185
185
  rawInputReplacement: (_extras, match, g1, g2) => {
186
- if (g1.match(Constants.CONST.REG_EXP.EMOJIS) || !g1.trim()) {
186
+ if (!g1.trim()) {
187
187
  return match;
188
188
  }
189
189
  return `<a href="${str_1.default.sanitizeURL(g2)}" data-raw-href="${g2}" data-link-variant="labeled" target="_blank" rel="noreferrer noopener">${g1.trim()}</a>`;
@@ -1093,6 +1093,172 @@ class ExpensiMark {
1093
1093
  originalContent = str_1.default.replaceAll(originalContent, '\n', '');
1094
1094
  return Utils.escape(originalContent);
1095
1095
  }
1096
+ /**
1097
+ * Determines the end position to truncate the HTML content while considering word boundaries.
1098
+ *
1099
+ * @param {string} content - The HTML content to be truncated.
1100
+ * @param {number} tailPosition - The position up to which the content should be considered.
1101
+ * @param {number} maxLength - The maximum length of the truncated content.
1102
+ * @param {number} totalLength - The length of the content processed so far.
1103
+ * @param {Object} opts - Options to customize the truncation.
1104
+ * @returns {number} The calculated position to truncate the content.
1105
+ */
1106
+ getEndPosition(content, tailPosition, maxLength, totalLength, opts) {
1107
+ const WORD_BREAK_REGEX = /\W+/g;
1108
+ // Calculate the default position to truncate based on the maximum length and the length of the content processed so far
1109
+ const defaultPosition = maxLength - totalLength;
1110
+ // Define the slop value, which determines the tolerance for cutting off content near the maximum length
1111
+ const slop = opts.slop;
1112
+ if (!slop)
1113
+ return defaultPosition;
1114
+ // Initialize the position to the default position
1115
+ let position = defaultPosition;
1116
+ // Determine if the default position is considered "short" based on the slop value
1117
+ const isShort = defaultPosition < slop;
1118
+ // Calculate the position within the slop range
1119
+ const slopPos = isShort ? defaultPosition : slop - 1;
1120
+ // Extract the substring to analyze for word boundaries, considering the slop and tail position
1121
+ const substr = content.slice(isShort ? 0 : defaultPosition - slop, tailPosition !== undefined ? tailPosition : defaultPosition + slop);
1122
+ // Find the first word boundary within the substring
1123
+ const wordBreakMatch = WORD_BREAK_REGEX.exec(substr);
1124
+ // Adjust the position to avoid truncating in the middle of a word if the option is enabled
1125
+ if (!opts.truncateLastWord) {
1126
+ if (tailPosition && substr.length <= tailPosition) {
1127
+ // If tail position is defined and the substring length is within the tail position, set position to the substring length
1128
+ position = substr.length;
1129
+ }
1130
+ else {
1131
+ // Iterate through word boundary matches to adjust the position
1132
+ while (wordBreakMatch !== null) {
1133
+ if (wordBreakMatch.index < slopPos) {
1134
+ // If the word boundary is before the slop position, adjust position backward
1135
+ position = defaultPosition - (slopPos - wordBreakMatch.index);
1136
+ if (wordBreakMatch.index === 0 && defaultPosition <= 1) {
1137
+ break;
1138
+ }
1139
+ }
1140
+ else if (wordBreakMatch.index === slopPos) {
1141
+ // If the word boundary is at the slop position, set position to the default position
1142
+ position = defaultPosition;
1143
+ break;
1144
+ }
1145
+ else {
1146
+ // If the word boundary is after the slop position, adjust position forward
1147
+ position = defaultPosition + (wordBreakMatch.index - slopPos);
1148
+ break;
1149
+ }
1150
+ }
1151
+ }
1152
+ // If the character at the determined position is a whitespace, adjust position backward
1153
+ if (content.charAt(position - 1).match(/\s$/)) {
1154
+ position--;
1155
+ }
1156
+ }
1157
+ // Return the calculated position to truncate the content
1158
+ return position;
1159
+ }
1160
+ /**
1161
+ * Truncate HTML string and keep tag safe.
1162
+ * pulled from https://github.com/huang47/nodejs-html-truncate/blob/master/lib/truncate.js
1163
+ *
1164
+ * @param {string} html - The string that needs to be truncated
1165
+ * @param {number} maxLength - Length of truncated string
1166
+ * @param {Object} [options] - Optional configuration options
1167
+ * @returns {string} The truncated string
1168
+ */
1169
+ truncateHTML(html, maxLength, options) {
1170
+ const EMPTY_STRING = '';
1171
+ const DEFAULT_TRUNCATE_SYMBOL = '...';
1172
+ const DEFAULT_SLOP = Math.min(10, maxLength);
1173
+ const tagsStack = [];
1174
+ const KEY_VALUE_REGEX = '((?:\\s+(?:\\w+|-)+(?:\\s*=\\s*(?:"(?:\\\\.|[^"\\\\])*"|\'(?:\\\\.|[^\'\\\\])*\'|[^\'">\\s]+))?)*)';
1175
+ const IS_CLOSE_REGEX = '\\s*\\/?\\s*';
1176
+ const CLOSE_REGEX = '\\s*\\/\\s*';
1177
+ const SELF_CLOSE_REGEX = new RegExp(`<\\/?(\\w+)${KEY_VALUE_REGEX}${CLOSE_REGEX}>`);
1178
+ const HTML_TAG_REGEX = new RegExp(`<\\/?(\\w+)${KEY_VALUE_REGEX}${IS_CLOSE_REGEX}>`);
1179
+ const URL_REGEX = /(((ftp|https?):\/\/)[\\-\w@:%_\\+.~#?,&\\/\\/=]+)|((mailto:)?[_.\w\\-]+@([\w][\w\\-]+\.)+[a-zA-Z]{2,3})/g;
1180
+ const IMAGE_TAG_REGEX = new RegExp(`<img\\s*${KEY_VALUE_REGEX}${CLOSE_REGEX}>`);
1181
+ let truncatedContent = EMPTY_STRING;
1182
+ let totalLength = 0;
1183
+ let matches = HTML_TAG_REGEX.exec(html);
1184
+ let endResult;
1185
+ let index;
1186
+ let tag;
1187
+ let selfClose = null;
1188
+ let htmlString = html;
1189
+ const opts = Object.assign({ ellipsis: DEFAULT_TRUNCATE_SYMBOL, truncateLastWord: true, slop: DEFAULT_SLOP }, options);
1190
+ function removeImageTag(content) {
1191
+ const match = IMAGE_TAG_REGEX.exec(content);
1192
+ if (!match) {
1193
+ return content;
1194
+ }
1195
+ const matchIndex = match.index;
1196
+ const matchLength = match[0].length;
1197
+ return content.substring(0, matchIndex) + content.substring(matchIndex + matchLength);
1198
+ }
1199
+ function closeTags(tags) {
1200
+ return tags
1201
+ .reverse()
1202
+ .map((mappedTag) => {
1203
+ return `</${mappedTag}>`;
1204
+ })
1205
+ .join('');
1206
+ }
1207
+ while (matches) {
1208
+ matches = HTML_TAG_REGEX.exec(htmlString);
1209
+ if (!matches) {
1210
+ if (totalLength >= maxLength) {
1211
+ break;
1212
+ }
1213
+ matches = URL_REGEX.exec(htmlString);
1214
+ if (!matches || matches.index >= maxLength) {
1215
+ truncatedContent += htmlString.substring(0, this.getEndPosition(htmlString, undefined, maxLength, totalLength, opts));
1216
+ break;
1217
+ }
1218
+ while (matches) {
1219
+ endResult = matches[0];
1220
+ if (endResult !== null) {
1221
+ index = matches.index;
1222
+ truncatedContent += htmlString.substring(0, index + endResult.length - totalLength);
1223
+ htmlString = htmlString.substring(index + endResult.length);
1224
+ matches = URL_REGEX.exec(htmlString);
1225
+ }
1226
+ }
1227
+ break;
1228
+ }
1229
+ endResult = matches[0];
1230
+ index = matches.index;
1231
+ if (totalLength + index > maxLength) {
1232
+ truncatedContent += htmlString.substring(0, this.getEndPosition(htmlString, index, maxLength, totalLength, opts));
1233
+ break;
1234
+ }
1235
+ else {
1236
+ totalLength += index;
1237
+ truncatedContent += htmlString.substring(0, index);
1238
+ }
1239
+ if (endResult[1] === '/') {
1240
+ tagsStack.pop();
1241
+ selfClose = null;
1242
+ }
1243
+ else {
1244
+ selfClose = SELF_CLOSE_REGEX.exec(endResult);
1245
+ if (!selfClose) {
1246
+ tag = matches[1];
1247
+ tagsStack.push(tag);
1248
+ }
1249
+ }
1250
+ truncatedContent += selfClose ? selfClose[0] : endResult;
1251
+ htmlString = htmlString.substring(index + endResult.length); // Update htmlString
1252
+ }
1253
+ if (htmlString.length > maxLength - totalLength && opts.ellipsis) {
1254
+ truncatedContent += opts.ellipsis ? '...' : '';
1255
+ }
1256
+ truncatedContent += closeTags(tagsStack);
1257
+ if (opts.removeImageTag) {
1258
+ truncatedContent = removeImageTag(truncatedContent);
1259
+ }
1260
+ return truncatedContent;
1261
+ }
1096
1262
  /**
1097
1263
  * Replaces text with a replacement based on a regex
1098
1264
  * @param text - The text to replace
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "expensify-common",
3
- "version": "2.0.42",
3
+ "version": "2.0.44",
4
4
  "author": "Expensify, Inc.",
5
5
  "description": "Expensify libraries and components shared across different repos",
6
6
  "homepage": "https://expensify.com",