fix(mobile/ios): close notification code blocks only at line-ending fences (#9349)

Follow-up to [#9346 notification message
text](https://github.com/block/buzz/pull/9346), from [Kyle's
review](https://github.com/block/buzz/pull/9346#discussion_r4539634396).

A fenced code block that contains three backticks, such as a `"```"`
string literal, closed at those backticks. The rest of its code then got
the prose rules, so `"**literal**"` lost its asterisks in the
notification. A fenced block now closes only at a fence at least as long
as its opening fence that ends its line, as in CommonMark and the in-app
preview.

Along the way, fences the old regex handled loosely are now consistent
with the app: a block fenced with four or more backticks keeps its code
(and can hold a three-backtick fence), and a block's opening line is
dropped as its language even when it contains a backtick. A one-line
fenced span (`` ```just ci``` ``) and an unclosed fence running to the
end of the text, on one line or several, are unchanged.

**What gets better, what doesn't get worse:** code inside a fenced block
now shows exactly as written. The change is one regex in the
notification body function, and every existing shared case still passes.

### Validation

- Added tests: six shared cases in
`test-fixtures/notification-body.json`: backticks inside a block don't
close it, a closing fence at the end of the last code line closes the
block, a four-backtick block keeps its code, a four-backtick block can
hold a three-backtick fence, an unclosed one-line fence runs to the end,
and an opening line with a backtick is dropped. All 112 BuzzPushKit
XCTest cases and the Swift Testing suite pass.
- No visible change outside notification text for fenced code, so there
are no new screenshots. Human testing is waived; the shared-case run
covers the changed behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-12 14:02:32 -08:00
committed by GitHub
parent 432df8aa52
commit defc2d5ea3
3 changed files with 40 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
# Notification message text
A [mobile push notification](../../CONTEXT.md#mobile-push-notification) for a message shows the message's own text as its [notification body](../../CONTEXT.md#notification-body), derived the same way on every platform. The body keeps the message's line breaks, the text of code, and bare URLs. It drops the Markdown syntax around them: code fences and their language, inline-code backticks, the `[…](…)` of links and images (keeping the link text or image alt text), and `**` and `~~` outside code. A link label or a bold run may contain code, and a code fence with no closing fence runs to the end of the text, as in CommonMark. Runs of spaces collapse outside code, and three or more line breaks become one blank line. Other Markdown (quotes, lists, headings, single `*` or `_`) stays as typed, because stripping it would also mangle ordinary text such as `snake_case` and code identifiers.
A [mobile push notification](../../CONTEXT.md#mobile-push-notification) for a message shows the message's own text as its [notification body](../../CONTEXT.md#notification-body), derived the same way on every platform. The body keeps the message's line breaks, the text of code, and bare URLs. It drops the Markdown syntax around them: code fences and their language, inline-code backticks, the `[…](…)` of links and images (keeping the link text or image alt text), and `**` and `~~` outside code. A link label or a bold run may contain code, a fenced block closes only at a fence at least as long as its opening fence that ends its line, so backticks inside its code don't end it, and a fence with no closing fence runs to the end of the text, as in CommonMark. Runs of spaces collapse outside code, and three or more line breaks become one blank line. Other Markdown (quotes, lists, headings, single `*` or `_`) stays as typed, because stripping it would also mangle ordinary text such as `snake_case` and code identifiers.
The body is cut only where the platform would otherwise cut it. iOS shows at most 256 UTF-16 code units of a body, even when the notification is expanded, and cuts the rest without an ellipsis, often mid-word. So the body is at most 256 UTF-16 code units: a longer one keeps whole characters up to 255 units and ends with "…", so the reader can tell there is more. A platform that shows more of a body than iOS may raise its own cap to what it shows, but never lower it below that. The body is built from at most the first 4096 UTF-16 code units of the content (whole characters), which bounds the work for very long messages; when that cut drops text, the body ends with "…" too.

View File

@@ -684,11 +684,15 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
/// whitespace in the prefix exceed about 3.8K units.
static let previewInputLimit = 4096
/// A fenced code block (with an optional language line) or an inline code
/// span. A fence with no closing fence runs to the end of the text, as in
/// CommonMark, which also covers a block cut by `previewInputLimit`.
/// A fenced span on one line, a fenced code block, or an inline code span.
/// A block's opening line (a fence of three or more backticks, then an
/// optional language) is dropped, and the block closes only at a fence at
/// least as long that ends its line, so backticks inside the code (a "```"
/// string literal) don't end it. A fence with no closing fence runs to the
/// end of the text, as in CommonMark, which also covers a block cut by
/// `previewInputLimit`. Group 2 is the block's fence; the others hold code.
private static let previewCode = try! NSRegularExpression(
pattern: #"```(?:[^\n`]*\n)?([\s\S]*?)(?:\n?```|\z)|`([^`\n]*)`"#)
pattern: #"```([^\n]*?)(?:```|\z)|(`{3,})[^\n]*\n([\s\S]*?)(?:\n?\2`*[ \t]*(?=\n|\z)|\z)|`([^`\n]*)`"#)
/// Stands in for a code span while the prose rules run over the whole
/// text, so a link label or a bold run can contain code. Private-use
@@ -725,7 +729,7 @@ public final class BuzzPushNotificationResolver: BuzzPushNotificationResolving {
var position = 0
for match in previewCode.matches(in: input, range: NSRange(location: 0, length: text.length)) {
prose += text.substring(with: NSRange(location: position, length: match.range.location - position))
let body = match.range(at: 1).location != NSNotFound ? match.range(at: 1) : match.range(at: 2)
let body = [1, 3, 4].map(match.range(at:)).first { $0.location != NSNotFound }!
prose += previewCodeOpen + String(codes.count) + previewCodeClose
codes.append(text.substring(with: body))
position = NSMaxRange(match.range)

View File

@@ -37,6 +37,36 @@
"input": "Run ```just ci``` first",
"expected": "Run just ci first"
},
{
"name": "backticks inside a fenced block don't close it",
"input": "```js\nconst fence = \"```\";\nconst text = \"**literal**\";\n```\nDone",
"expected": "const fence = \"```\";\nconst text = \"**literal**\";\nDone"
},
{
"name": "a closing fence at the end of the last code line closes the block",
"input": "```\nlet x = 1```\n**After**",
"expected": "let x = 1\nAfter"
},
{
"name": "a block fenced with four backticks keeps its code",
"input": "````\nlet text = \"**literal**\";\n````\n**After**",
"expected": "let text = \"**literal**\";\nAfter"
},
{
"name": "a four-backtick block can hold a three-backtick fence",
"input": "````\n```\n**literal**\n```\n````\n**After**",
"expected": "```\n**literal**\n```\nAfter"
},
{
"name": "an unclosed fence on one line runs to the end of the text",
"input": "Run ```**literal**",
"expected": "Run **literal**"
},
{
"name": "a block's opening line is dropped even when it has a backtick",
"input": "```code with `tick`\nmore\n```",
"expected": "more"
},
{
"name": "inline code keeps its text",
"input": "Call `fetch()` again",