html-text.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import i18n from 'i18next';
  2. import { UNA_URL, APP_URL } from 'app/config';
  3. // Named entities: all of HTML 4 (what UNA and editors emit) plus '. The
  4. // full HTML5 table (2000+ names, the `html-entities` package) cost ~13 KB gz on
  5. // every page; unknown names are left as written.
  6. const LATIN1_ENTITIES = // U+00A0..U+00FF in order
  7. 'nbsp iexcl cent pound curren yen brvbar sect uml copy ordf laquo not shy reg macr deg plusmn sup2 sup3 acute micro para middot cedil sup1 ordm raquo frac14 frac12 frac34 iquest Agrave Aacute Acirc Atilde Auml Aring AElig Ccedil Egrave Eacute Ecirc Euml Igrave Iacute Icirc Iuml ETH Ntilde Ograve Oacute Ocirc Otilde Ouml times Oslash Ugrave Uacute Ucirc Uuml Yacute THORN szlig agrave aacute acirc atilde auml aring aelig ccedil egrave eacute ecirc euml igrave iacute icirc iuml eth ntilde ograve oacute ocirc otilde ouml divide oslash ugrave uacute ucirc uuml yacute thorn yuml';
  8. const GREEK_UPPER = 'Alpha Beta Gamma Delta Epsilon Zeta Eta Theta Iota Kappa Lambda Mu Nu Xi Omicron Pi Rho _ Sigma Tau Upsilon Phi Chi Psi Omega'; // U+0391..
  9. const GREEK_LOWER = 'alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigmaf sigma tau upsilon phi chi psi omega'; // U+03B1..
  10. const OTHER_ENTITIES: Record<string, number> = {
  11. quot: 34, amp: 38, apos: 39, lt: 60, gt: 62,
  12. OElig: 338, oelig: 339, Scaron: 352, scaron: 353, Yuml: 376, fnof: 402, circ: 710, tilde: 732,
  13. thetasym: 977, upsih: 978, piv: 982,
  14. ensp: 8194, emsp: 8195, thinsp: 8201, zwnj: 8204, zwj: 8205, lrm: 8206, rlm: 8207,
  15. ndash: 8211, mdash: 8212, lsquo: 8216, rsquo: 8217, sbquo: 8218, ldquo: 8220, rdquo: 8221, bdquo: 8222,
  16. dagger: 8224, Dagger: 8225, bull: 8226, hellip: 8230, permil: 8240, prime: 8242, Prime: 8243,
  17. lsaquo: 8249, rsaquo: 8250, oline: 8254, frasl: 8260, euro: 8364,
  18. image: 8465, weierp: 8472, real: 8476, trade: 8482, alefsym: 8501,
  19. larr: 8592, uarr: 8593, rarr: 8594, darr: 8595, harr: 8596, crarr: 8629,
  20. lArr: 8656, uArr: 8657, rArr: 8658, dArr: 8659, hArr: 8660,
  21. forall: 8704, part: 8706, exist: 8707, empty: 8709, nabla: 8711, isin: 8712, notin: 8713, ni: 8715,
  22. prod: 8719, sum: 8721, minus: 8722, lowast: 8727, radic: 8730, prop: 8733, infin: 8734, ang: 8736,
  23. and: 8743, or: 8744, cap: 8745, cup: 8746, int: 8747, there4: 8756, sim: 8764, cong: 8773, asymp: 8776,
  24. ne: 8800, equiv: 8801, le: 8804, ge: 8805, sub: 8834, sup: 8835, nsub: 8836, sube: 8838, supe: 8839,
  25. oplus: 8853, otimes: 8855, perp: 8869, sdot: 8901, lceil: 8968, rceil: 8969, lfloor: 8970, rfloor: 8971,
  26. lang: 9001, rang: 9002, loz: 9674, spades: 9824, clubs: 9827, hearts: 9829, diams: 9830,
  27. };
  28. // &#128;..&#159; mean Windows-1252 characters in HTML (e.g. &#150; is an en dash).
  29. const WINDOWS_1252: Record<number, number> = {
  30. 128: 8364, 130: 8218, 131: 402, 132: 8222, 133: 8230, 134: 8224, 135: 8225, 136: 710, 137: 8240,
  31. 138: 352, 139: 8249, 140: 338, 142: 381, 145: 8216, 146: 8217, 147: 8220, 148: 8221, 149: 8226,
  32. 150: 8211, 151: 8212, 152: 732, 153: 8482, 154: 353, 155: 8250, 156: 339, 158: 382, 159: 376,
  33. };
  34. let namedEntities: Record<string, string> | null = null;
  35. // Legacy names (Latin-1 + amp/lt/gt/quot) also decode without `;`, as a prefix: `&copy2` → `©2`.
  36. let legacyEntities: Set<string> | null = null;
  37. function getNamedEntities(): Record<string, string> {
  38. if (namedEntities) return namedEntities;
  39. const map: Record<string, string> = {};
  40. const addRun = (names: string, first: number) => names.split(' ').forEach((name, i) => {
  41. if (name !== '_') map[name] = String.fromCharCode(first + i);
  42. });
  43. addRun(LATIN1_ENTITIES, 0xa0);
  44. addRun(GREEK_UPPER, 0x391);
  45. addRun(GREEK_LOWER, 0x3b1);
  46. for (const [name, code] of Object.entries(OTHER_ENTITIES)) map[name] = String.fromCodePoint(code);
  47. legacyEntities = new Set([...LATIN1_ENTITIES.split(' '), 'amp', 'lt', 'gt', 'quot']);
  48. return (namedEntities = map);
  49. }
  50. function decodeNamed(match: string, name: string): string {
  51. const entities = getNamedEntities();
  52. if (match.endsWith(';') && entities[name]) return entities[name];
  53. for (let len = Math.min(name.length, 6); len >= 2; len--) {
  54. const prefix = name.slice(0, len);
  55. if (legacyEntities!.has(prefix)) return entities[prefix] + match.slice(1 + len);
  56. }
  57. return match;
  58. }
  59. function decodeCodePoint(code: number): string {
  60. if (WINDOWS_1252[code]) return String.fromCodePoint(WINDOWS_1252[code]!);
  61. if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return '�';
  62. return String.fromCodePoint(code);
  63. }
  64. /** Decode HTML entities (`&amp;`, `&#8212;`, `&#x2014;`, `&laquo;`) into characters. */
  65. export function decodeText(str: string): string {
  66. if (!str) return '';
  67. if (str.indexOf('&') === -1) return str;
  68. return str.replace(/&(?:#(\d+)|#[xX]([0-9a-fA-F]+)|([A-Za-z][A-Za-z0-9]*));?/g, (match, dec, hex, name) => {
  69. if (dec) return decodeCodePoint(parseInt(dec, 10));
  70. if (hex) return decodeCodePoint(parseInt(hex, 16));
  71. return decodeNamed(match, name);
  72. });
  73. }
  74. export const htmlDecode = decodeText; // Alias for backward compatibility
  75. /**
  76. * `decodeText` for a value that may not be a string: React children, optional
  77. * labels, `alt`. UNA sends titles and names HTML-escaped (`Rock &amp; Roll`),
  78. * so every plain-text sink that shows one (headings, alt, accessible names,
  79. * native buttons, document titles) decodes it. Anything but a string passes through.
  80. */
  81. export function decodeTextNode<T>(value: T): T {
  82. return (typeof value === 'string' ? decodeText(value) : value) as T;
  83. }
  84. export function truncateString(str: string, num: number): string {
  85. if (str.length <= num) {
  86. return str;
  87. }
  88. return str.slice(0, num) + '...';
  89. }
  90. /** Cut HTML to `maxLength` text chars (and `maxLines`), closing any open tags. */
  91. export function truncateHTML(html: string | null | undefined, maxLength: number, maxLines: number | null = null): string {
  92. if (!html) return '';
  93. let textLength = 0;
  94. let lineCount = 0;
  95. let truncated = '';
  96. // Regex to find tags and text fragments
  97. const tagOrTextRegex = /<\/?([a-z][a-z0-9]*)\b[^>]*>|[^<]+/gi;
  98. let match: RegExpExecArray | null;
  99. // Stack to track open tags
  100. const tags: string[] = [];
  101. // Walk HTML and truncate text content to maxLength or maxLines
  102. while ((match = tagOrTextRegex.exec(html))) {
  103. const part = match[0];
  104. if (part[0] === '<') {
  105. // If this is a tag, check whether it is opening or closing
  106. const tagName = match[1]!.toLowerCase();
  107. const isClosingTag = part[1] === '/';
  108. if (!isClosingTag) {
  109. // Check if this tag starts a new line
  110. if (/br|p|div|li|h[1-6]|section|article|header|footer/.test(tagName)) {
  111. lineCount++;
  112. if (maxLines !== null && lineCount > maxLines) break;
  113. }
  114. if (!/br|hr|img|input|link|meta|area|base|col|command|embed|keygen|param|source|track|wbr/.test(tagName)) {
  115. tags.push(tagName);
  116. }
  117. } else if (isClosingTag) {
  118. const lastIndex = tags.lastIndexOf(tagName);
  119. if (lastIndex !== -1) {
  120. tags.splice(lastIndex, 1);
  121. }
  122. }
  123. // Append tag to result without incrementing text counter
  124. truncated += part;
  125. } else {
  126. // Text fragment
  127. // Check for explicit line breaks in text
  128. const textLines = part.split(/\r\n|\r|\n/);
  129. let textPartTruncated = '';
  130. for (let i = 0; i < textLines.length; i++) {
  131. if (i > 0 || (lineCount === 0 && textLines[i]!.trim().length > 0)) {
  132. lineCount++;
  133. if (maxLines !== null && lineCount > maxLines) break;
  134. }
  135. if (i > 0) textPartTruncated += '\n';
  136. const remainingChars = maxLength - textLength;
  137. const line = textLines[i]!;
  138. if (line.length > remainingChars) {
  139. textPartTruncated += line.substring(0, remainingChars);
  140. textLength += remainingChars;
  141. break;
  142. } else {
  143. textPartTruncated += line;
  144. textLength += line.length;
  145. }
  146. }
  147. truncated += textPartTruncated;
  148. if (maxLines !== null && lineCount > maxLines) break;
  149. if (textLength >= maxLength) break;
  150. }
  151. }
  152. // Close all unclosed tags
  153. while (tags.length) {
  154. truncated += `</${tags.pop()}>`;
  155. }
  156. return truncated;
  157. }
  158. export function firstLetterCap(string: string): string {
  159. return string.charAt(0).toUpperCase() + string.slice(1);
  160. }
  161. function getPlural(key: string, count: number): string {
  162. let lastDigit = count % 10;
  163. let lastTwoDigits = count % 100;
  164. if (count == 0)
  165. return key + '_0';
  166. if (count == 1) {
  167. return key + '_1';
  168. }
  169. if ([2, 3, 4].includes(lastDigit) && ![12, 13, 14].includes(lastTwoDigits)) {
  170. return key + '_2';
  171. }
  172. return key + '_plural';
  173. }
  174. /** Translate a plural key (`key_0` / `key_1` / `key_2` / `key_plural`) with `{ count }`. */
  175. export function tp(key: string, count: number, isHideData = false): string {
  176. let ct: number | string = count;
  177. if (isHideData)
  178. ct = '';
  179. // `count: ''` (isHideData) hides the number in the string; i18next types expect a number.
  180. return i18n.t(getPlural(key, count), { count: ct as number });
  181. }
  182. /** Last external (non-own-domain) URL in the text, skipping `excluded`; null if none. */
  183. export function linkify2(text: string, excluded: string[] = []): string | null {
  184. const urlRegex = /\b((https?:\/\/)|(www\.))((([0-9a-zA-Z_!~*'().&=+$%-]+:)?[0-9a-zA-Z_!~*'().&=+$%-]+@)?(([0-9]{1,3}\.){3}[0-9]{1,3}|([0-9a-zA-Z_!~*'()-]+\.)*([0-9a-zA-Z][0-9a-zA-Z-]{0,61})?[0-9a-zA-Z]\.[a-zA-Z]{2,16})(:[0-9]{1,4})?((\/[0-9a-zA-Z_!~*'().;?:@&=+$,%#-]*)*))/g;
  185. let matches = Array.from(text.matchAll(urlRegex)).reverse();
  186. for (let match of matches) {
  187. if ([APP_URL, UNA_URL].every(domain => !match[0].includes(domain))) {
  188. const url = match[0];
  189. if (!excluded.some(ex => url.includes(ex))) {
  190. return url;
  191. }
  192. }
  193. }
  194. return null;
  195. }
  196. export function linkify3(inputText: string): string {
  197. var replacedText: string, replacePattern1: RegExp, replacePattern2: RegExp, replacePattern3: RegExp;
  198. //URLs starting with http://, https://, or ftp://
  199. replacePattern1 = /(\b(https?|ftp):\/\/[-A-Z0-9+&@#\/%?=~_|!:,.;]*[-A-Z0-9+&@#\/%=~_|])/gim;
  200. replacedText = inputText.replace(replacePattern1, '<a href="$1" target="_blank">$1</a>');
  201. //URLs starting with "www." (without // before it, or it'd re-link the ones done above).
  202. replacePattern2 = /(^|[^\/])(www\.[\S]+(\b|$))/gim;
  203. replacedText = replacedText.replace(replacePattern2, '$1<a href="http://$2" target="_blank">$2</a>');
  204. //Change email addresses to mailto:: links.
  205. replacePattern3 = /(\w+@[a-zA-Z_]+?\.[a-zA-Z]{2,6})/gim;
  206. replacedText = replacedText.replace(replacePattern3, '<a href="mailto:$1">$1</a>');
  207. return replacedText;
  208. }
  209. function escapeRegExp(string: string): string {
  210. return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
  211. }
  212. /**
  213. * Turn absolute own-site (UNA_URL) links into root-relative ones.
  214. * `src` values are left alone: storage images live on the UNA host, not the app.
  215. */
  216. export function clearLinks<T extends string | null | undefined>(text: T): T {
  217. if (!text)
  218. return text;
  219. const regex = new RegExp(`(\\bsrc\\s*=\\s*["']\\s*)?${escapeRegExp(UNA_URL)}`, 'gi');
  220. return text!.replace(regex, (match, src) => (src ? match : '/')) as T;
  221. }
  222. /** Unwrap mention spans and relativize own links. `attrs` / `htmlSpecialChars` are legacy, unused. */
  223. export function linkify(text: string, attrs = '', htmlSpecialChars = false): string {
  224. return clearLinks(text.replace(/<span contenteditable="false">(.*?)<\/span>/g, '$1'));
  225. }
  226. export function isEmoji(s: string): boolean {
  227. const emojiRegex = /(?:\p{Extended_Pictographic}|\p{Emoji_Presentation})/u;
  228. return !!s.match(emojiRegex);
  229. }
  230. export function stripTags(s: any): any {
  231. if (s)
  232. return String(s).replace(/(<([^>]+)>)/ig, '').replace(/\s+/g, ' ');
  233. return s;
  234. }
  235. export function looksLikeHtml(s: unknown): boolean {
  236. return /<[a-z][\s\S]*>/i.test(String(s ?? ''));
  237. }
  238. /**
  239. * True when rich-editor HTML has visible text or user-created line breaks.
  240. * Ignores TipTap/enriched empty states: one <br>, trailing ProseMirror <br>, empty <p>.
  241. */
  242. export function editorHtmlHasContent(html: unknown): boolean {
  243. if (!html || typeof html !== 'string') return false
  244. if (stripTags(html).trim().length > 0) return true
  245. const inner = html
  246. .replace(/^<html>/i, '')
  247. .replace(/<\/html>$/i, '')
  248. .trim()
  249. .replace(/<br[^>]*\bProseMirror-trailingBreak\b[^>]*>/gi, '')
  250. const emptyParagraphRegex = /<p[^>]*>(?:\s|&nbsp;|<br[^>]*\/?>)*<\/p>/gi
  251. const emptyParagraphs = inner.match(emptyParagraphRegex) || []
  252. if (emptyParagraphs.length > 1) return true
  253. const withoutEmptyPs = inner.replace(emptyParagraphRegex, '').trim()
  254. const standaloneBrCount = (withoutEmptyPs.match(/<br\b/gi) || []).length
  255. if (standaloneBrCount > 1) return true
  256. // Single <br> with no other markup is the normalized empty document — not content.
  257. if (standaloneBrCount === 1 && !withoutEmptyPs.replace(/<br\b[^>]*\/?>/gi, '').trim()) {
  258. return false
  259. }
  260. return withoutEmptyPs.length > 0
  261. }
  262. /*export function stripTagsWithLinks(s) {
  263. if (s)
  264. return String(s).replace(/<(?!\/?(a|p|br)(?=>|\s.*>))\/?.*?>/ig, '').replace(/\s+/g, ' ');
  265. return s;
  266. }*/
  267. export function stripTagsWithLinks(s: any, allowed: string[] = ['a', 'p', 'br']): any {
  268. if (s) {
  269. const allowedTags = allowed.join('|');
  270. const regex = new RegExp(`<(?!(\\/?)(${allowedTags})(?=>|\\s.*>))\\/?.*?>`, 'ig');
  271. return String(s).replace(regex, '').replace(/\s+/g, ' ');
  272. }
  273. return s;
  274. }
  275. export function removeEmptyTags(html: string): string {
  276. return html.replace(/<p>(?:\s|&nbsp;)*<\/p>/gi, '');
  277. }
  278. /**
  279. * True when rich-editor HTML is worth sending: visible text or embedded media.
  280. * Blank lines, `<html>` wrappers, `<br>` and `&nbsp;` alone are not.
  281. */
  282. export function editorHtmlIsSubmittable(html: unknown): boolean {
  283. if (!html || typeof html !== 'string') return false
  284. if (/<(img|video|audio|iframe)\b/i.test(html)) return true
  285. const text = String(stripTags(html))
  286. .replace(/&nbsp;|&#160;| |​|/gi, ' ')
  287. .trim()
  288. return text.length > 0
  289. }
  290. /** True when a form files value holds at least one file (`[]` after a clear is empty). */
  291. export function hasFiles(value: unknown): boolean {
  292. return Array.isArray(value) ? value.length > 0 : !!value
  293. }