| 1 | export type InlineMathDecision = "math" | "literal"; |
| 2 | |
| 3 | const PURE_NUMBER = /^\d+(?:,\d{3})*(?:\.\d+)?$/; |
| 4 | |
| 5 | /** |
| 6 | * Decide how an `inlineMath` node produced by remark-math should render. |
| 7 | * |
| 8 | * Pure-number spans with glued delimiters are mathematical by default |
| 9 | * (assistant-ui `escapeCurrencyDollars` parity): prose currency pairs such |
| 10 | * as `$5 and $10` never reach this classifier with a numeric body, because |
| 11 | * their word-containing spans match no math pattern below and restore |
| 12 | * verbatim. Other non-math content is also restored verbatim as text. |
| 13 | */ |
| 14 | export function classifyInlineMath(math: string): InlineMathDecision { |
| 15 | if (!math || math !== math.trim() || math.includes("\n")) return "literal"; |
| 16 | if (math.includes("://") || math.includes("](")) return "literal"; |
| 17 | |
| 18 | if (PURE_NUMBER.test(math)) return "math"; |
| 19 | |
| 20 | // A percentage enclosed in explicit delimiters is mathematical notation. |
| 21 | // latexNormalizeForKatex escapes its `%` for KaTeX. |
| 22 | if (/^\d+(?:,\d{3})*(?:\.\d+)?%$/.test(math)) return "math"; |
| 23 | |
| 24 | // Number followed by variable: implicit multiplication (2.5x, 3y^2). |
| 25 | if (/^\d+(?:\.\d+)?[A-Za-z](?:[A-Za-z0-9^_{}]*)?$/.test(math)) return "math"; |
| 26 | // Number with LaTeX escape: 10\%, 5\cdot3. |
| 27 | if (/^\d+(?:\.\d+)?\\(?:%|[A-Za-z]+)(?:\{[^{}]*\})?(?:[A-Za-z0-9\\{}^_+\-*/=<>.()]*)$/.test(math)) return "math"; |
| 28 | |
| 29 | // Unary plus/minus: +2, -x, +\alpha, - 3.14. |
| 30 | if (/^[+\-]\s*(?:\d+(?:\.\d+)?|[A-Za-z\\])/.test(math)) return "math"; |
| 31 | |
| 32 | // LaTeX command (\alpha, \frac{x}{y}, \tfrac12, ...). |
| 33 | if (/\\[A-Za-z]+/.test(math)) return "math"; |
| 34 | if (/[\^_{}|]/.test(math)) return "math"; |
| 35 | if (/^[+\-=<>±∓]$/.test(math)) return "math"; |
| 36 | if (/\b(?:alpha|beta|gamma|sum|int|prod|lim|infty|sqrt|frac|sin|cos|tan|log|ln|max|min|partial|nabla|left|right)\b/.test(math)) return "math"; |
| 37 | if (/^[A-Za-z]{1,6}\s*\([^)]{1,80}\)$/.test(math)) return "math"; |
| 38 | if (/^[A-Za-z]'{1,}\s*\([^)]{1,80}\)$/.test(math)) return "math"; |
| 39 | if (/^(?:\(\d+\))+$/.test(math)) return "math"; |
| 40 | if (/[A-Za-z0-9)\]}]\s*[+\-*/=<>]\s*[+\-]?\s*[A-Za-z0-9([{\\]/.test(math)) return "math"; |
| 41 | |
| 42 | // One-sided relation/equality against an implicit operand. The complete |
| 43 | // anchors are important: `=1 dollar` must not be accepted by a prefix match. |
| 44 | const operand = String.raw`(?:[+\-]?\s*(?:\d+(?:\.\d+)?|[A-Za-z]|\\[A-Za-z]+))`; |
| 45 | const relation = String.raw`(?:<=?|>=?|=|≠|≤|≥)`; |
| 46 | if (new RegExp(`^(?:${relation}\\s*${operand}|${operand}\\s*${relation})$`).test(math)) return "math"; |
| 47 | |
| 48 | if (/^\(?(?:[A-Za-z0-9]|\\[A-Za-z]+)(?:\s*,\s*(?:[A-Za-z0-9]|\\[A-Za-z]+)){1,10}\)?$/.test(math)) return "math"; |
| 49 | if (/^\[[A-Za-z0-9^_+\-,.\\\s{}]+\]$/.test(math)) return "math"; |
| 50 | |
| 51 | if (/[A-Za-z]\s+[A-Za-z]/.test(math)) return "literal"; |
| 52 | if (/^[A-Z][A-Z0-9_]{1,}$/.test(math)) return "literal"; |
| 53 | if (/^v\d+(?:\.\d+)*$/i.test(math)) return "literal"; |
| 54 | if (/^[A-Za-z]{2,}$/.test(math)) return "literal"; |
| 55 | |
| 56 | if (/^(?:[A-Za-z]|\\[A-Za-z]+)'{1,}$/.test(math)) return "math"; |
| 57 | return /^[A-Za-z]$/.test(math) ? "math" : "literal"; |
| 58 | } |
| 59 | |
| 60 | export function isLikelyInlineMath(math: string): boolean { |
| 61 | return classifyInlineMath(math) === "math"; |
| 62 | } |
| 63 |