fix(mobile): leave tilde fences and autolink backslashes literal

Treat `~~~` fences as code when shielding escapes, as bubble segments
already do, so their backslashes stay literal. Encode backslashes in an
`<url>` autolink as `&#92;` in its label and `%5C` in its destination,
so none escapes the synthesized `]` or `)`.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-02 09:19:38 -08:00
parent ddc8b55592
commit a3964ab08c
2 changed files with 26 additions and 7 deletions

View File

@@ -14,6 +14,7 @@ final _bareLinkPattern = RegExp(
);
final _trailingPunctuationPattern = RegExp(r'[.,!?:;]+$');
final _backtickRunPattern = RegExp(r'`+');
final _codeRunPattern = RegExp(r'`+|~{3,}');
/// Converts supported Buzz and HTTP(S) autolinks and bare links into Markdown
/// links while leaving inline and fenced code untouched. Punctuation peeling
@@ -22,7 +23,7 @@ String normalizeBareLinks(String content) =>
mapMarkdownProse(content, _normalizeLinkSegment);
/// Applies [transform] to each run of [content] outside inline and fenced
/// code, leaving code untouched.
/// code, leaving code untouched. Fences may use backticks or tildes.
String mapMarkdownProse(String content, String Function(String) transform) {
final buffer = StringBuffer();
var offset = 0;
@@ -30,27 +31,38 @@ String mapMarkdownProse(String content, String Function(String) transform) {
var codeStart = 0;
var inlineDelimiterLength = 0;
var fenceDelimiterLength = 0;
var fenceChar = '';
while (offset < content.length) {
final run = _backtickRunPattern.matchAsPrefix(content, offset);
final run = _codeRunPattern.matchAsPrefix(content, offset);
if (run == null) {
offset++;
continue;
}
final runLength = run.end - run.start;
final runChar = content[run.start];
if (fenceDelimiterLength > 0) {
if (_isClosingFence(content, run.start, run.end, fenceDelimiterLength)) {
if (runChar == fenceChar &&
_isClosingFence(content, run.start, run.end, fenceDelimiterLength)) {
buffer.write(content.substring(codeStart, run.end));
fenceDelimiterLength = 0;
proseStart = run.end;
}
} else if (inlineDelimiterLength > 0) {
if (runLength == inlineDelimiterLength) {
if (runChar == '`' && runLength == inlineDelimiterLength) {
buffer.write(content.substring(codeStart, run.end));
inlineDelimiterLength = 0;
proseStart = run.end;
}
} else if (runChar == '~') {
// Tildes only fence code; shorter runs are strikethrough.
if (_isOpeningFence(content, run.start, runLength)) {
buffer.write(transform(content.substring(proseStart, run.start)));
codeStart = run.start;
fenceDelimiterLength = runLength;
fenceChar = runChar;
}
} else if (_isEscaped(content, run.start)) {
// An escaped backtick is literal; the rest of its run may still open.
offset = run.start + 1;
@@ -65,6 +77,7 @@ String mapMarkdownProse(String content, String Function(String) transform) {
buffer.write(transform(content.substring(proseStart, run.start)));
codeStart = run.start;
fenceDelimiterLength = runLength;
fenceChar = runChar;
}
offset = run.end;
@@ -276,9 +289,8 @@ String _normalizeProseLinks(String segment) {
// An escaped `\<` is literal, not an autolink; its URL stays a bare link.
var normalized = segment.replaceAllMapped(
_autolinkPattern,
(match) => _isEscaped(segment, match.start)
? match[0]!
: '[${match[1]}](${match[1]})',
(match) =>
_isEscaped(segment, match.start) ? match[0]! : _autolink(match[1]!),
);
normalized = normalized.replaceAllMapped(
_bareLinkPattern,
@@ -287,6 +299,11 @@ String _normalizeProseLinks(String segment) {
return normalized;
}
// An autolink keeps its backslashes literally. Encode them so none is read
// as an escape of the synthesized `]` or `)`.
String _autolink(String url) =>
'[${url.replaceAll(r'\', '&#92;')}](${url.replaceAll(r'\', '%5C')})';
String _normalizeBareLink(String segment, Match match) {
final matched = match[0]!;
var url = matched;

View File

@@ -514,6 +514,7 @@ void main() {
(r'\`literal\` then `a\#b`', '`literal` then '),
// Authored stand-in characters are not mistaken for escapes.
('\uFF0A \u2060\uFF0A \\#', '\uFF0A \u2060\uFF0A #'),
('~~~\nx \\# y\n~~~', r'x \# y'),
// gpt_markdown shows this unparsed; the stand-in still reads as #.
('~~a\nb \\#c~~', '\uFF03c'),
// A bare link's trailing backslash stays outside the link.
@@ -521,6 +522,7 @@ void main() {
(r'in buzz://channel/9f18f3c6-b5e5-453d-b36d-65487913be0b.\', r'.\'),
(r'*https://example.com/a\**', '*'),
(r'see \<https://example.com/b>', 'see <'),
(r'see <https://example.com/a\>', r'https://example.com/a\'),
(
'![shot \\#1](https://example.com/a.png)\n'
'![shot \\#2](https://example.com/b.png)',