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
25const 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
28const ORDERED_LIST_ITEM = /^ {0,3}\d{1,9}[.)](?:[ \t]+|$)/;
29const INDENTED_ORDERED_LIST_ITEM = /^( {4,})\d{1,9}[.)](?:[ \t]+|$)/;
30const ANY_LIST_ITEM = /^( *)(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/;
31const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/;
32
33export interface WideMarkdownBlockOptions {
34 readonly includeOrderedLists?: boolean;
35 readonly includeBlockquotes?: boolean;
36}
37
38function 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
46function 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
68function hasBlockquote(text: string): boolean {
69 return text.split("\n").some((line) => BLOCKQUOTE_PREFIX.test(line));
70}
71
72function 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
100function 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
108export 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}