| 1 | /** |
| 2 | * Detects markdown that the renderer draws as a block requiring a definite |
| 3 | * user-bubble width: fenced and indented code blocks, GFM tables, ordered |
| 4 | * lists, and blockquotes when requested by the caller. |
| 5 | * |
| 6 | * Fenced code blocks and tables report an intrinsic width equal to their |
| 7 | * widest line, which is effectively unbounded. A user bubble sizes itself |
| 8 | * from its content |
| 9 | * (`maxWidth` with no `width`), so Android lays the bubble's children out |
| 10 | * during the unclamped intrinsic pass — where the surrounding paragraphs |
| 11 | * collapse to a single line — and never repositions them once the width is |
| 12 | * clamped back to `maxWidth`. The result is siblings drawn on top of each |
| 13 | * other inside an over-tall bubble. Pinning the bubble's width removes the |
| 14 | * intrinsic pass entirely, which is the same reason review-comment bubbles |
| 15 | * already carry an explicit width. |
| 16 | * |
| 17 | * Ordered lists hit the same Android layout bug because each item contains a |
| 18 | * flexing content column inside a shrink-to-fit row. |
| 19 | * |
| 20 | * A four-space ordered marker is only treated as a list when the preceding |
| 21 | * non-empty line is a less-indented list item. This keeps standalone indented |
| 22 | * code out of the heuristic while covering nested lists. |
| 23 | */ |
| 24 | |
| 25 | const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m; |
| 26 | // Trades some precision for a simple check, favoring false positives over false |
| 27 | // negatives: list-shaped paragraph may get a wider bubble |
| 28 | const ORDERED_LIST_ITEM = /^ {0,3}\d{1,9}[.)](?:[ \t]+|$)/; |
| 29 | const INDENTED_ORDERED_LIST_ITEM = /^( {4,})\d{1,9}[.)](?:[ \t]+|$)/; |
| 30 | const ANY_LIST_ITEM = /^( *)(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/; |
| 31 | const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/; |
| 32 | |
| 33 | export interface WideMarkdownBlockOptions { |
| 34 | readonly includeOrderedLists?: boolean; |
| 35 | readonly includeBlockquotes?: boolean; |
| 36 | } |
| 37 | |
| 38 | function stripBlockquotePrefixes(line: string): string { |
| 39 | let content = line; |
| 40 | while (BLOCKQUOTE_PREFIX.test(content)) { |
| 41 | content = content.replace(BLOCKQUOTE_PREFIX, ""); |
| 42 | } |
| 43 | return content; |
| 44 | } |
| 45 | |
| 46 | function hasIndentedCodeBlock(text: string): boolean { |
| 47 | return text.split("\n").some((rawLine) => { |
| 48 | const line = stripBlockquotePrefixes(rawLine); |
| 49 | let column = 0; |
| 50 | let index = 0; |
| 51 | |
| 52 | // Markdown tabs advance to the next four-column stop. |
| 53 | while (index < line.length) { |
| 54 | if (line[index] === " ") { |
| 55 | column += 1; |
| 56 | } else if (line[index] === "\t") { |
| 57 | column += 4 - (column % 4); |
| 58 | } else { |
| 59 | break; |
| 60 | } |
| 61 | index += 1; |
| 62 | } |
| 63 | |
| 64 | return column >= 4 && index < line.length && line[index] !== "\r"; |
| 65 | }); |
| 66 | } |
| 67 | |
| 68 | function hasBlockquote(text: string): boolean { |
| 69 | return text.split("\n").some((line) => BLOCKQUOTE_PREFIX.test(line)); |
| 70 | } |
| 71 | |
| 72 | function hasOrderedListItem(text: string): boolean { |
| 73 | let previousNonEmptyLine: string | null = null; |
| 74 | |
| 75 | for (const rawLine of text.split("\n")) { |
| 76 | const line = stripBlockquotePrefixes(rawLine); |
| 77 | if (ORDERED_LIST_ITEM.test(line)) { |
| 78 | return true; |
| 79 | } |
| 80 | |
| 81 | const nestedMatch = INDENTED_ORDERED_LIST_ITEM.exec(line); |
| 82 | const parentMatch = |
| 83 | previousNonEmptyLine === null ? null : ANY_LIST_ITEM.exec(previousNonEmptyLine); |
| 84 | if ( |
| 85 | nestedMatch?.[1] !== undefined && |
| 86 | parentMatch?.[1] !== undefined && |
| 87 | parentMatch[1].length < nestedMatch[1].length |
| 88 | ) { |
| 89 | return true; |
| 90 | } |
| 91 | |
| 92 | if (line.trim().length > 0) { |
| 93 | previousNonEmptyLine = line; |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | return false; |
| 98 | } |
| 99 | |
| 100 | function isTableDelimiterRow(line: string): boolean { |
| 101 | const trimmed = line.trim(); |
| 102 | if (!trimmed.includes("|") || !trimmed.includes("-")) { |
| 103 | return false; |
| 104 | } |
| 105 | return /^[|\-: \t]+$/.test(trimmed); |
| 106 | } |
| 107 | |
| 108 | export function hasWideMarkdownBlock( |
| 109 | text: string, |
| 110 | options: WideMarkdownBlockOptions = {}, |
| 111 | ): boolean { |
| 112 | if (FENCED_CODE_BLOCK.test(text)) { |
| 113 | return true; |
| 114 | } |
| 115 | if (options.includeBlockquotes === true && hasBlockquote(text)) { |
| 116 | return true; |
| 117 | } |
| 118 | if (hasIndentedCodeBlock(text)) { |
| 119 | return true; |
| 120 | } |
| 121 | if (options.includeOrderedLists !== false && hasOrderedListItem(text)) { |
| 122 | return true; |
| 123 | } |
| 124 | if (!text.includes("|")) { |
| 125 | return false; |
| 126 | } |
| 127 | return text.split("\n").some(isTableDelimiterRow); |
| 128 | } |