html-helpers.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203
  1. // Shared HTML preprocessing for the `Html` atom (native + web).
  2. //
  3. // `preprocessHtml` normalizes raw UNA content into a clean HTML string (decode
  4. // entities, linkify plain URLs, wrap loose text in <p>). Both platforms consume
  5. // the same output so native and web stay in sync. `sanitizeHtml` is web-only
  6. // defense (script/handler stripping) used before `dangerouslySetInnerHTML`.
  7. import { decodeText } from 'app/lib/util'
  8. import { linkifyHtml, normalizeLinkHref } from 'app/components/form-fields/editor-mention-html'
  9. // UNA messenger auto-linker (`bx-link`) stores bare hrefs: emails without
  10. // `mailto:` and domains without `https://`. `linkifyHtml` skips existing <a>
  11. // tags, so renderers must rewrite those hrefs or the browser treats them as
  12. // in-app relative paths (`/garysicard@hotmail.com`, `/molosserdogs.com/cpanel`).
  13. const BARE_EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,24}$/
  14. const ANCHOR_OPEN_RE = /<a\b([^>]*?)>/gi
  15. const HREF_ATTR_RE = /\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i
  16. export function normalizeRenderedHref(href: string) {
  17. if (!href || typeof href !== 'string') return href
  18. const trimmed = href.trim()
  19. if (!trimmed) return href
  20. if (BARE_EMAIL_RE.test(trimmed)) return `mailto:${trimmed}`
  21. return normalizeLinkHref(trimmed)
  22. }
  23. export function normalizeAnchorHrefs(html: string) {
  24. if (!html || typeof html !== 'string') return html
  25. ANCHOR_OPEN_RE.lastIndex = 0
  26. return html.replace(ANCHOR_OPEN_RE, (full, attrs) => {
  27. const m = attrs.match(HREF_ATTR_RE)
  28. if (!m) return full
  29. const raw = m[2] ?? m[3] ?? m[4] ?? ''
  30. const next = normalizeRenderedHref(raw)
  31. if (!next || next === raw) return full
  32. const quote = m[1][0] === '"' || m[1][0] === "'" ? m[1][0] : '"'
  33. return `<a${attrs.replace(m[0], `href=${quote}${next}${quote}`)}>`
  34. })
  35. }
  36. const LINK_TEXT_MAX = 30
  37. const decodeAmp = (s: string) => s.replace(/&amp;/g, '&')
  38. const stripScheme = (s: string) => s.replace(/^https?:\/\//i, '').replace(/^www\./i, '').replace(/\/$/, '')
  39. // Display text for a bare URL: no scheme/www, domain always kept whole, rest cut with "…"
  40. export function shortenUrlText(url: string) {
  41. const s = stripScheme(url)
  42. if (s.length <= LINK_TEXT_MAX) return s
  43. const hostLength = (s.split(/[/?#]/)[0] ?? s).length
  44. return s.slice(0, Math.max(LINK_TEXT_MAX - 1, hostLength)) + '…'
  45. }
  46. // Auto-linked URLs (anchor text == href, or href already cut upstream with "..."/"…")
  47. // get short display text; href stays full. Anchors with custom text (mentions,
  48. // [text](url)) are left as is.
  49. export function shortenLinkTexts(html: string) {
  50. if (!html || typeof html !== 'string') return html
  51. return html.replace(/<a\b([^>]*?)\bhref=(["'])(.*?)\2([^>]*)>([^<]*)<\/a>/gi, (match, before: string, quote: string, href: string, after: string, text: string) => {
  52. const textUrl = decodeAmp(text.trim())
  53. const fullUrl = decodeAmp(href)
  54. if (!/^(https?:\/\/|www\.)/i.test(textUrl))
  55. return match
  56. const cut = textUrl.match(/^(.*?)(\.\.\.|…)$/)
  57. const isAutoLink = cut
  58. ? stripScheme(fullUrl).startsWith(stripScheme(cut[1] ?? ''))
  59. : stripScheme(textUrl) === stripScheme(fullUrl)
  60. if (!isAutoLink || fullUrl.length <= LINK_TEXT_MAX)
  61. return match
  62. const short = shortenUrlText(fullUrl)
  63. const escaped = short.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
  64. const title = /\btitle=/i.test(before + after) ? '' : ` title=${quote}${href}${quote}`
  65. return `<a${before}href=${quote}${href}${quote}${after}${title}>${escaped}</a>`
  66. })
  67. }
  68. export const hasBlockHtml = (html: string) => (
  69. /<(p|div|ul|ol|li|h[1-6]|pre|blockquote|table|thead|tbody|tr)\b/i.test(html)
  70. )
  71. // Normalize raw content into a clean HTML string. Shared by native and web so the
  72. // two renderers receive identical input.
  73. export function preprocessHtml(data: any) {
  74. if (!data) return ''
  75. let html = decodeText(data).replace(/&nbsp;/g, ' ')
  76. html = html.replace(/\n|\r/g, '')
  77. // Make plain-text URLs/emails clickable (covers legacy content and platforms
  78. // where the editor didn't auto-link). Skips text already inside anchors/mentions/code.
  79. html = linkifyHtml(html)
  80. html = normalizeAnchorHrefs(html)
  81. html = shortenLinkTexts(html)
  82. if (html.trim() !== '' && !hasBlockHtml(html)) html = `<p>${html}</p>`
  83. return html
  84. }
  85. // UNA CMS can emit nested <a> tags (e.g. channel mention wrapping a keyword link).
  86. // HTML forbids anchor descendants; unwrap the innermost anchors, keeping content.
  87. export function flattenNestedAnchors(html: string) {
  88. if (!html || typeof html !== 'string') return html
  89. const nestedRe = /<a [^>]*>[^<]*<a [^>]*>(.*?)<\/a>/gi
  90. let out = html
  91. let guard = 0
  92. while (nestedRe.test(out) && guard < 20) {
  93. out = out.replace(nestedRe, (match) => match.replace(/<a [^>]*>(.*?)<\/a>/i, '$1'))
  94. guard += 1
  95. }
  96. return out
  97. }
  98. // Block tags that carry paragraph alignment.
  99. const ALIGN_BLOCK = /<(p|h[1-6]|li|blockquote|div)\b([^>]*)>/gi
  100. const ALIGN_CLASS = /(^|\s)text-(left|center|right)(?=\s|$)/
  101. const attrValue = (attrs: string, name: string) =>
  102. attrs.match(new RegExp(`\\s${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, 'i'))
  103. const withoutAttr = (attrs: string, name: string) =>
  104. attrs.replace(new RegExp(`\\s${name}\\s*=\\s*("[^"]*"|'[^']*'|[^\\s>]+)`, 'gi'), '')
  105. /** Paragraph alignment of an opening tag's attributes: its `text-*` class, else its `text-align` style. */
  106. export function alignFromAttrs(attrs: string | undefined): 'left' | 'center' | 'right' | undefined {
  107. if (!attrs) return undefined
  108. const cls = attrValue(attrs, 'class')
  109. const fromClass = (cls?.[2] ?? cls?.[3] ?? '').match(ALIGN_CLASS)?.[2]
  110. const style = attrValue(attrs, 'style')
  111. const fromStyle = (style?.[2] ?? style?.[3] ?? '').match(/text-align\s*:\s*(left|center|right)/i)?.[1]
  112. return (fromClass || fromStyle?.toLowerCase()) as 'left' | 'center' | 'right' | undefined
  113. }
  114. /**
  115. * Editor → stored HTML: `style="text-align: …"` on a block becomes a Tailwind class
  116. * (`text-center` / `text-right`; left is the default and is dropped). Inline styles are
  117. * stripped on save (stripInlinePresentation), a class survives it and UNA's purifier.
  118. */
  119. export function alignStylesToClasses(html: string) {
  120. if (!html || typeof html !== 'string') return html || ''
  121. return html.replace(ALIGN_BLOCK, (tag, name: string, attrs: string) => {
  122. const style = attrValue(attrs, 'style')
  123. const align = (style?.[2] ?? style?.[3] ?? '').match(/text-align\s*:\s*(left|center|right)/i)?.[1]?.toLowerCase()
  124. if (!align) return tag
  125. const cls = attrValue(attrs, 'class')
  126. const classes = (cls?.[2] ?? cls?.[3] ?? '').split(/\s+/).filter((c) => c && !ALIGN_CLASS.test(` ${c}`))
  127. if (align !== 'left') classes.push(`text-${align}`)
  128. const rest = withoutAttr(attrs, 'class')
  129. return `<${name}${rest}${classes.length ? ` class="${classes.join(' ')}"` : ''}>`
  130. })
  131. }
  132. /**
  133. * Stored HTML → enriched editor / native renderer: they read alignment from
  134. * `text-align` only, so a `text-*` class becomes that style again.
  135. */
  136. export function alignClassesToStyles(html: string) {
  137. if (!html || typeof html !== 'string') return html || ''
  138. return html.replace(ALIGN_BLOCK, (tag, name: string, attrs: string) => {
  139. const cls = attrValue(attrs, 'class')
  140. const align = (cls?.[2] ?? cls?.[3] ?? '').match(ALIGN_CLASS)?.[2]
  141. if (!align || /text-align\s*:/i.test(attrValue(attrs, 'style')?.[0] ?? '')) return tag
  142. const style = attrValue(attrs, 'style')
  143. const css = [(style?.[2] ?? style?.[3] ?? '').trim().replace(/;$/, ''), `text-align: ${align}`].filter(Boolean).join('; ')
  144. return `<${name}${withoutAttr(attrs, 'style')} style="${css}">`
  145. })
  146. }
  147. /**
  148. * Strip paste/docs presentation junk that fights design tokens (Word/Notion/docs
  149. * often wrap text in `<span style="color: …">` / `<font color=…>`).
  150. * Use on editor paste/save only — do not strip on render (legacy content stays as stored).
  151. * Safe for UNA content: mentions are `<a>`, not styled spans.
  152. */
  153. export function stripInlinePresentation(html: string) {
  154. if (!html || typeof html !== 'string') return html || ''
  155. return html
  156. // Inline CSS (quoted or bare).
  157. .replace(/\sstyle\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
  158. // Legacy presentational attributes.
  159. .replace(/\s(?:color|bgcolor|background|face|size)\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
  160. // Unwrap <font>…</font> (keep children).
  161. .replace(/<\/?font\b[^>]*>/gi, '')
  162. // Unwrap <span>…</span> left over after style strip (Word/TipTap Color).
  163. .replace(/<\/?span\b[^>]*>/gi, '')
  164. }
  165. // Lightweight web sanitizer used before `dangerouslySetInnerHTML`. Strips script/
  166. // style blocks, inline event handlers, and `javascript:` URLs. This is not a full
  167. // XSS suite — UNA content is server-owned — but it removes the obvious injection
  168. // vectors that raw innerHTML would otherwise execute.
  169. export function sanitizeHtml(html: string) {
  170. if (!html || typeof html !== 'string') return ''
  171. return html
  172. // Drop <script>/<style> blocks including their contents.
  173. .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
  174. // Drop dangling/self-closing script/style/meta/base tags.
  175. .replace(/<\/?(script|style|meta|base)\b[^>]*>/gi, '')
  176. // Remove inline event handlers: on*="..." / on*='...' / on*=unquoted.
  177. .replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, '')
  178. .replace(/\son[a-z]+\s*=\s*'[^']*'/gi, '')
  179. .replace(/\son[a-z]+\s*=\s*[^\s>]+/gi, '')
  180. // Neutralize javascript:/vbscript: in href/src attributes.
  181. .replace(/\b(href|src)\s*=\s*("|')\s*(javascript|vbscript):[^"']*\2/gi, '$1=$2#$2')
  182. }