-
Notifications
You must be signed in to change notification settings - Fork 249
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
model: Add wrapWithBacktickFence, to use with quote-and-reply
For the corresponding logic used in the web app and zulip-mobile, see web/shared/src/fenced_code.ts. I believe the logic here differs from that in just this way: it follows the CommonMark spec more closely by disqualifying backtick-fence lines where the "info string" has a backtick, since that's not allowed: > If the info string comes after a backtick fence, it may not > contain any backtick characters. (The reason for this restriction > is that otherwise some inline code would be incorrectly > interpreted as the beginning of a fenced code block.) Regarding the new file lib/model/compose.dart, we do have existing code that could reasonably move here, but it's pretty simple. It's the code that gives the upload-file Markdown; see registerUploadStart and registerUploadEnd in [ContentTextEditingController]. Related: #116
- Loading branch information
1 parent
b79bd77
commit b6556a6
Showing
2 changed files
with
318 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
import 'dart:math'; | ||
|
||
// | ||
// Put functions for nontrivial message-content generation in this file. | ||
// | ||
// If it's complicated enough to need tests, it should go in here. | ||
// | ||
|
||
// https://spec.commonmark.org/0.30/#fenced-code-blocks | ||
final RegExp _openingBacktickFenceRegex = (() { | ||
// Recognize a fence with "up to three spaces of indentation". | ||
// Servers don't recognize fences that start with spaces, as of Server 7.0: | ||
// https://chat.zulip.org/#narrow/stream/6-frontend/topic/quote-and-reply.20fence.20length/near/1588273 | ||
// but that's a bug, since those fences are valid in the spec. | ||
// Still, it's harmless to make our own fence longer even if the server | ||
// wouldn't notice the internal fence that we're steering clear of, | ||
// and if servers *do* start following the spec by noticing indented internal | ||
// fences, then this client behavior will be nice. | ||
const lineStart = r'^ {0,3}'; | ||
|
||
// The backticks, captured so we can see how many. | ||
const backticks = r'(`{3,})'; | ||
|
||
// The "info string" plus (meaningless) leading or trailing spaces or tabs. | ||
// It can't contain backticks. | ||
const trailing = r'[^`]*$'; | ||
return RegExp(lineStart + backticks + trailing, multiLine: true); | ||
})(); | ||
|
||
/// The shortest backtick fence that's longer than any in [content]. | ||
/// | ||
/// Expressed as a number of backticks. | ||
/// | ||
/// Use this for quote-and-reply or anything else that requires wrapping | ||
/// Markdown in a backtick fence. | ||
/// | ||
/// See the CommonMark spec, which Zulip servers should but don't always follow: | ||
/// https://spec.commonmark.org/0.30/#fenced-code-blocks | ||
int getUnusedBacktickFenceLength(String content) { | ||
final matches = _openingBacktickFenceRegex.allMatches(content); | ||
int result = 3; | ||
for (final match in matches) { | ||
result = max(result, match[1]!.length + 1); | ||
} | ||
return result; | ||
} | ||
|
||
/// Wrap Markdown [content] with opening and closing backtick fences. | ||
/// | ||
/// For example, for this Markdown: | ||
/// | ||
/// ```javascript | ||
/// console.log('Hello world!'); | ||
/// ``` | ||
/// | ||
/// this function, with `infoString: 'quote'`, gives | ||
/// | ||
/// ````quote | ||
/// ```javascript | ||
/// console.log('Hello world!'); | ||
/// ``` | ||
/// ```` | ||
/// | ||
/// See the CommonMark spec, which Zulip servers should but don't always follow: | ||
/// https://spec.commonmark.org/0.30/#fenced-code-blocks | ||
// In [content], indented code blocks | ||
// ( https://spec.commonmark.org/0.30/#indented-code-blocks ) | ||
// and code blocks fenced with tildes should make no difference to the | ||
// backtick fences we choose here; this function ignores them. | ||
String wrapWithBacktickFence({required String content, String? infoString}) { | ||
assert(infoString == null || !infoString.contains('`')); | ||
assert(infoString == null || infoString.trim() == infoString); | ||
|
||
StringBuffer resultBuffer = StringBuffer(); | ||
|
||
// CommonMark doesn't require closing fences to be paired: | ||
// https://github.com/zulip/zulip-flutter/pull/179#discussion_r1228712591 | ||
// | ||
// - We need our opening fence to be long enough that it won't be closed by | ||
// any fence in the content. | ||
// - We need our closing fence to be long enough that it will close any | ||
// outstanding opening fences in the content. | ||
final fenceLength = getUnusedBacktickFenceLength(content); | ||
|
||
resultBuffer.write('`' * fenceLength); | ||
if (infoString != null) { | ||
resultBuffer.write(infoString); | ||
} | ||
resultBuffer.write('\n'); | ||
resultBuffer.write(content); | ||
if (content.isNotEmpty && !content.endsWith('\n')) { | ||
resultBuffer.write('\n'); | ||
} | ||
resultBuffer.write('`' * fenceLength); | ||
resultBuffer.write('\n'); | ||
return resultBuffer.toString(); | ||
} | ||
|
||
// TODO more, like /near links to messages in conversations | ||
// (also to be used in quote-and-reply) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
import 'package:checks/checks.dart'; | ||
import 'package:test/scaffolding.dart'; | ||
import 'package:zulip/model/compose.dart'; | ||
|
||
void main() { | ||
group('wrapWithBacktickFence', () { | ||
/// Check `wrapWithBacktickFence` on example input and expected output. | ||
/// | ||
/// The intended input (content passed to `wrapWithBacktickFence`) | ||
/// is straightforward to infer from `expected`. | ||
/// To do that, this helper takes `expected` and removes the opening and | ||
/// closing fences. | ||
/// | ||
/// Then we have the input to the test, as well as the expected output. | ||
void checkFenceWrap(String expected, {String? infoString, bool chopNewline = false}) { | ||
final re = RegExp(r'^.*?\n(.*\n|).*\n$', dotAll: true); | ||
String content = re.firstMatch(expected)![1]!; | ||
if (chopNewline) content = content.substring(0, content.length - 1); | ||
check(wrapWithBacktickFence(content: content, infoString: infoString)).equals(expected); | ||
} | ||
|
||
test('empty content', () { | ||
checkFenceWrap(''' | ||
``` | ||
``` | ||
'''); | ||
}); | ||
|
||
test('content consisting of blank lines', () { | ||
checkFenceWrap(''' | ||
``` | ||
``` | ||
'''); | ||
}); | ||
|
||
test('single line with no code blocks', () { | ||
checkFenceWrap(''' | ||
``` | ||
hello world | ||
``` | ||
'''); | ||
}); | ||
|
||
test('multiple lines with no code blocks', () { | ||
checkFenceWrap(''' | ||
``` | ||
hello | ||
world | ||
``` | ||
'''); | ||
}); | ||
|
||
test('no code blocks; incomplete final line', () { | ||
checkFenceWrap(chopNewline: true, ''' | ||
``` | ||
hello | ||
world | ||
``` | ||
'''); | ||
}); | ||
|
||
test('three-backtick block', () { | ||
checkFenceWrap(''' | ||
```` | ||
hello | ||
``` | ||
code | ||
``` | ||
world | ||
```` | ||
'''); | ||
}); | ||
|
||
test('multiple three-backtick blocks; one has info string', () { | ||
checkFenceWrap(''' | ||
```` | ||
hello | ||
``` | ||
code | ||
``` | ||
world | ||
```javascript | ||
// more code | ||
``` | ||
```` | ||
'''); | ||
}); | ||
|
||
test('whitespace around info string', () { | ||
checkFenceWrap(''' | ||
```` | ||
``` javascript | ||
// hello world | ||
``` | ||
```` | ||
'''); | ||
}); | ||
|
||
test('four-backtick block', () { | ||
checkFenceWrap(''' | ||
````` | ||
```` | ||
hello world | ||
```` | ||
````` | ||
'''); | ||
}); | ||
|
||
test('five-backtick block', () { | ||
checkFenceWrap(''' | ||
`````` | ||
````` | ||
hello world | ||
````` | ||
`````` | ||
'''); | ||
}); | ||
|
||
test('five-backtick block; incomplete final line', () { | ||
checkFenceWrap(chopNewline: true, ''' | ||
`````` | ||
````` | ||
hello world | ||
````` | ||
`````` | ||
'''); | ||
}); | ||
|
||
test('three-, four-, and five-backtick blocks', () { | ||
checkFenceWrap(''' | ||
`````` | ||
``` | ||
hello world | ||
``` | ||
```` | ||
hello world | ||
```` | ||
````` | ||
hello world | ||
````` | ||
`````` | ||
'''); | ||
}); | ||
|
||
test('dangling opening fence', () { | ||
checkFenceWrap(''' | ||
````` | ||
````javascript | ||
// hello world | ||
````` | ||
'''); | ||
}); | ||
|
||
test('code blocks marked by indentation or tilde fences don\'t affect result', () { | ||
checkFenceWrap(''' | ||
``` | ||
// hello world | ||
~~~~~~ | ||
code | ||
~~~~~~ | ||
``` | ||
'''); | ||
}); | ||
|
||
test('backtick fences may be indented up to three spaces', () { | ||
checkFenceWrap(''' | ||
```` | ||
``` | ||
```` | ||
'''); | ||
checkFenceWrap(''' | ||
```` | ||
``` | ||
```` | ||
'''); | ||
checkFenceWrap(''' | ||
```` | ||
``` | ||
```` | ||
'''); | ||
// but at 4 spaces of indentation it no longer counts: | ||
checkFenceWrap(''' | ||
``` | ||
``` | ||
``` | ||
'''); | ||
}); | ||
|
||
test('fence ignored if info string has backtick', () { | ||
checkFenceWrap(''' | ||
``` | ||
```java`script | ||
hello | ||
``` | ||
'''); | ||
}); | ||
|
||
test('with info string', () { | ||
checkFenceWrap(infoString: 'info', ''' | ||
`````info | ||
``` | ||
hello | ||
``` | ||
info | ||
````python | ||
hello | ||
```` | ||
````` | ||
'''); | ||
}); | ||
}); | ||
} |