/** * Detects markdown that the renderer draws as a block requiring a definite * user-bubble width: fenced and indented code blocks, GFM tables, ordered * lists, and blockquotes when requested by the caller. * * Fenced code blocks and tables report an intrinsic width equal to their * widest line, which is effectively unbounded. A user bubble sizes itself * from its content * (`maxWidth` with no `width`), so Android lays the bubble's children out * during the unclamped intrinsic pass — where the surrounding paragraphs * collapse to a single line — and never repositions them once the width is * clamped back to `maxWidth`. The result is siblings drawn on top of each * other inside an over-tall bubble. Pinning the bubble's width removes the * intrinsic pass entirely, which is the same reason review-comment bubbles * already carry an explicit width. * * Ordered lists hit the same Android layout bug because each item contains a * flexing content column inside a shrink-to-fit row. * * A four-space ordered marker is only treated as a list when the preceding * non-empty line is a less-indented list item. This keeps standalone indented * code out of the heuristic while covering nested lists. */const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m;// Trades some precision for a simple check, favoring false positives over false// negatives: list-shaped paragraph may get a wider bubbleconst ORDERED_LIST_ITEM = /^ {0,3}\d{1,9}[.)](?:[ \t]+|$)/;const INDENTED_ORDERED_LIST_ITEM = /^( {4,})\d{1,9}[.)](?:[ \t]+|$)/;const ANY_LIST_ITEM = /^( *)(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/;const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/;export interface WideMarkdownBlockOptions { readonly includeOrderedLists?: boolean; readonly includeBlockquotes?: boolean;}function stripBlockquotePrefixes(line: string): string { let content = line; while (BLOCKQUOTE_PREFIX.test(content)) { content = content.replace(BLOCKQUOTE_PREFIX, ""); } return content;}function hasIndentedCodeBlock(text: string): boolean { return text.split("\n").some((rawLine) => { const line = stripBlockquotePrefixes(rawLine); let column = 0; let index = 0; // Markdown tabs advance to the next four-column stop. while (index < line.length) { if (line[index] === " ") { column += 1; } else if (line[index] === "\t") { column += 4 - (column % 4); } else { break; } index += 1; } return column >= 4 && index < line.length && line[index] !== "\r"; });}function hasBlockquote(text: string): boolean { return text.split("\n").some((line) => BLOCKQUOTE_PREFIX.test(line));}function hasOrderedListItem(text: string): boolean { let previousNonEmptyLine: string | null = null; for (const rawLine of text.split("\n")) { const line = stripBlockquotePrefixes(rawLine); if (ORDERED_LIST_ITEM.test(line)) { return true; } const nestedMatch = INDENTED_ORDERED_LIST_ITEM.exec(line); const parentMatch = previousNonEmptyLine === null ? null : ANY_LIST_ITEM.exec(previousNonEmptyLine); if (nestedMatch && parentMatch && parentMatch[1].length < nestedMatch[1].length) { return true; } if (line.trim().length > 0) { previousNonEmptyLine = line; } } return false;}function isTableDelimiterRow(line: string): boolean { const trimmed = line.trim(); if (!trimmed.includes("|") || !trimmed.includes("-")) { return false; } return /^[|\-: \t]+$/.test(trimmed);}export function hasWideMarkdownBlock( text: string, options: WideMarkdownBlockOptions = {},): boolean { if (FENCED_CODE_BLOCK.test(text)) { return true; } if (options.includeBlockquotes === true && hasBlockquote(text)) { return true; } if (hasIndentedCodeBlock(text)) { return true; } if (options.includeOrderedLists !== false && hasOrderedListItem(text)) { return true; } if (!text.includes("|")) { return false; } return text.split("\n").some(isTableDelimiterRow);}
/**
* Detects markdown that the renderer draws as a block requiring a definite
* user-bubble width: fenced and indented code blocks, GFM tables, ordered
* lists, and blockquotes when requested by the caller.
*
* Fenced code blocks and tables report an intrinsic width equal to their
* widest line, which is effectively unbounded. A user bubble sizes itself
* from its content
* (`maxWidth` with no `width`), so Android lays the bubble's children out
* during the unclamped intrinsic pass — where the surrounding paragraphs
* collapse to a single line — and never repositions them once the width is
* clamped back to `maxWidth`. The result is siblings drawn on top of each
* other inside an over-tall bubble. Pinning the bubble's width removes the
* intrinsic pass entirely, which is the same reason review-comment bubbles
* already carry an explicit width.
*
* Ordered lists hit the same Android layout bug because each item contains a
* flexing content column inside a shrink-to-fit row.
*
* A four-space ordered marker is only treated as a list when the preceding
* non-empty line is a less-indented list item. This keeps standalone indented
* code out of the heuristic while covering nested lists.
*/
const FENCED_CODE_BLOCK = /^ {0,3}(?:```|~~~)/m;
// Trades some precision for a simple check, favoring false positives over false
// negatives: list-shaped paragraph may get a wider bubble
const ORDERED_LIST_ITEM = /^ {0,3}\d{1,9}[.)](?:[ \t]+|$)/;
const INDENTED_ORDERED_LIST_ITEM = /^( {4,})\d{1,9}[.)](?:[ \t]+|$)/;
const ANY_LIST_ITEM = /^( *)(?:[-+*]|\d{1,9}[.)])(?:[ \t]+|$)/;
const BLOCKQUOTE_PREFIX = /^ {0,3}>[ \t]?/;
export interface WideMarkdownBlockOptions {
readonly includeOrderedLists?: boolean;
readonly includeBlockquotes?: boolean;
}
function stripBlockquotePrefixes(line: string): string {
let content = line;
while (BLOCKQUOTE_PREFIX.test(content)) {
content = content.replace(BLOCKQUOTE_PREFIX, "");
}
return content;
}
function hasIndentedCodeBlock(text: string): boolean {
return text.split("\n").some((rawLine) => {
const line = stripBlockquotePrefixes(rawLine);
let column = 0;
let index = 0;
// Markdown tabs advance to the next four-column stop.
while (index < line.length) {
if (line[index] === " ") {
column += 1;
} else if (line[index] === "\t") {
column += 4 - (column % 4);
} else {
break;
}
index += 1;
}
return column >= 4 && index < line.length && line[index] !== "\r";
});
}
function hasBlockquote(text: string): boolean {
return text.split("\n").some((line) => BLOCKQUOTE_PREFIX.test(line));
}
function hasOrderedListItem(text: string): boolean {
let previousNonEmptyLine: string | null = null;
for (const rawLine of text.split("\n")) {
const line = stripBlockquotePrefixes(rawLine);
if (ORDERED_LIST_ITEM.test(line)) {
return true;
}
const nestedMatch = INDENTED_ORDERED_LIST_ITEM.exec(line);
const parentMatch =
previousNonEmptyLine === null ? null : ANY_LIST_ITEM.exec(previousNonEmptyLine);
if (nestedMatch && parentMatch && parentMatch[1].length < nestedMatch[1].length) {
return true;
}
if (line.trim().length > 0) {
previousNonEmptyLine = line;
}
}
return false;
}
function isTableDelimiterRow(line: string): boolean {
const trimmed = line.trim();
if (!trimmed.includes("|") || !trimmed.includes("-")) {
return false;
}
return /^[|\-: \t]+$/.test(trimmed);
}
export function hasWideMarkdownBlock(
text: string,
options: WideMarkdownBlockOptions = {},
): boolean {
if (FENCED_CODE_BLOCK.test(text)) {
return true;
}
if (options.includeBlockquotes === true && hasBlockquote(text)) {
return true;
}
if (hasIndentedCodeBlock(text)) {
return true;
}
if (options.includeOrderedLists !== false && hasOrderedListItem(text)) {
return true;
}
if (!text.includes("|")) {
return false;
}
return text.split("\n").some(isTableDelimiterRow);
}