diff --git a/apps/ios/Resources/Licenses/SwiftMath.txt b/apps/ios/Resources/Licenses/SwiftMath.txt new file mode 100644 index 000000000000..dfc39d4e8ff1 --- /dev/null +++ b/apps/ios/Resources/Licenses/SwiftMath.txt @@ -0,0 +1,26 @@ +SwiftMath + +MIT License + +Copyright (c) 2023 Computer Inspirations + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +SwiftMath bundles math fonts licensed separately under the GUST Font License +and the SIL Open Font License. diff --git a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift index 47376901ed62..7f4dcca66ebe 100644 --- a/apps/ios/Tests/SwiftUIRenderSmokeTests.swift +++ b/apps/ios/Tests/SwiftUIRenderSmokeTests.swift @@ -3,6 +3,7 @@ import SwiftUI import Testing import UIKit @testable import OpenClaw +@testable import OpenClawChatUI struct SwiftUIRenderSmokeTests { @MainActor private static func host(_ view: some View, size: CGSize? = nil) -> UIWindow { @@ -111,6 +112,34 @@ struct SwiftUIRenderSmokeTests { } } + @Test @MainActor func `display math builds valid and fallback view hierarchies`() { + for typeSize in [DynamicTypeSize.large, .accessibility2] { + let root = VStack { + ChatMathBlockView(block: ChatMathBlock( + latex: #"\frac{-b \pm \sqrt{b^2 - 4ac}}{2a}"#, + isComplete: true), textColor: OpenClawChatTheme.assistantText) + ChatMathBlockView(block: ChatMathBlock( + latex: #"\notARealCommand{"#, + isComplete: true), textColor: OpenClawChatTheme.assistantText) + ChatMathBlockView(block: ChatMathBlock( + latex: "α + β = γ", + isComplete: true), textColor: OpenClawChatTheme.assistantText) + ChatMathBlockView(block: ChatMathBlock( + latex: String(repeating: "{", count: 65) + "x", + isComplete: true), textColor: OpenClawChatTheme.assistantText) + ChatMathBlockView(block: ChatMathBlock( + latex: String(repeating: #"\bar"#, count: 129) + "x", + isComplete: true), textColor: OpenClawChatTheme.assistantText) + ChatMathBlockView(block: ChatMathBlock( + latex: #"x\textcolor{#fff}{}"#, + isComplete: true), textColor: OpenClawChatTheme.assistantText) + } + .environment(\.dynamicTypeSize, typeSize) + + _ = Self.host(root, size: CGSize(width: 393, height: 240)) + } + } + @Test @MainActor func `root tabs builds device orientation shell matrix`() { for scenario in Self.rootTabsShellScenarios() { let appModel = NodeAppModel() diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved index 2362c6611a11..e32b7e37d012 100644 --- a/apps/macos/Package.resolved +++ b/apps/macos/Package.resolved @@ -126,6 +126,15 @@ "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", "version" : "1.6.4" } + }, + { + "identity" : "swiftmath", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mgriebling/SwiftMath", + "state" : { + "revision" : "fa8244ed032f4a1ade4cb0571bf87d2f1a9fd2d7", + "version" : "1.7.3" + } } ], "version" : 3 diff --git a/apps/shared/OpenClawKit/Package.swift b/apps/shared/OpenClawKit/Package.swift index d024ebd2fd35..43e4b1cdec11 100644 --- a/apps/shared/OpenClawKit/Package.swift +++ b/apps/shared/OpenClawKit/Package.swift @@ -19,6 +19,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.1"), + .package(url: "https://github.com/mgriebling/SwiftMath", exact: "1.7.3"), .package(url: "https://github.com/swiftlang/swift-markdown", exact: "0.8.0"), ], targets: [ @@ -46,6 +47,7 @@ let package = Package( dependencies: [ "OpenClawKit", .product(name: "Markdown", package: "swift-markdown"), + .product(name: "SwiftMath", package: "SwiftMath"), ], path: "Sources/OpenClawChatUI", swiftSettings: [ diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift index 8dd5926c4a1f..1af385bd7006 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift @@ -2,10 +2,11 @@ import Foundation import Markdown /// One renderable block of a chat message. Prose stays on the -/// AttributedString pipeline; fenced code and GFM tables get dedicated views. +/// AttributedString pipeline; fenced code, display math, and GFM tables get dedicated views. enum ChatMarkdownBlock: Equatable { case prose(String) case code(ChatCodeBlock) + case math(ChatMathBlock) case table(ChatMarkdownTable) } @@ -17,6 +18,12 @@ struct ChatCodeBlock: Equatable { let isComplete: Bool } +struct ChatMathBlock: Equatable { + let latex: String + /// True when the delimiter was closed or the message finished streaming. + let isComplete: Bool +} + struct ChatMarkdownTable: Equatable { enum ColumnAlignment: Equatable { case leading @@ -50,45 +57,45 @@ enum ChatMarkdownBlockSyntax { } enum ChatMarkdownBlockSegmenter { + static let maxMathBytes = 5000 static let maxTableBytes = 20000 static let maxTableRows = 100 static let maxTableColumns = 12 static let maxTableCells = 600 - /// Extracts only top-level fenced code and GFM tables. The parser owns + /// Extracts only top-level fenced code, display math, and GFM tables. The parser owns /// CommonMark container and reference semantics; nested blocks stay in the /// surrounding prose range unchanged. static func segments(markdown: String, isComplete: Bool) -> [ChatMarkdownBlock] { let source = SourceBuffer(markdown) let document = Document(parsing: source.markdown) - var blocks: [ChatMarkdownBlock] = [] - var proseStart = 0 - - func appendProse(until end: Int) { - guard proseStart < end else { return } - blocks.append(contentsOf: self.proseOnly(Array(source.lines[proseStart..= proseStart else { + guard let lineRange = source.lineRange(for: child.range) else { continue } + if mathResult.protectedRanges.contains(where: { $0.contains(lineRange.lowerBound) }) { continue } if let code = child as? Markdown.CodeBlock, let opener = FenceOpener.parse(source.lines[lineRange.lowerBound]) { - appendProse(until: lineRange.lowerBound) let language = code.language? .split(whereSeparator: \.isWhitespace) .first .map { $0.lowercased() } let closed = lineRange.count > 1 && opener.isClose(source.lines[lineRange.index(before: lineRange.endIndex)]) - blocks.append(.code(ChatCodeBlock( - language: language, - code: self.dropStructuralCodeNewline(code.code), - isComplete: closed || isComplete))) - proseStart = lineRange.upperBound + extractions.append(Extraction( + lineRange: lineRange, + block: .code(ChatCodeBlock( + language: language, + code: self.dropStructuralCodeNewline(code.code), + isComplete: closed || isComplete)))) continue } @@ -105,12 +112,31 @@ enum ChatMarkdownBlockSegmenter { { continue } - appendProse(until: tableRange.lowerBound) - blocks.append(.table(rendered)) - proseStart = tableRange.upperBound + extractions.append(Extraction(lineRange: tableRange, block: .table(rendered))) } } + extractions.sort { left, right in + if left.lineRange.lowerBound != right.lineRange.lowerBound { + return left.lineRange.lowerBound < right.lineRange.lowerBound + } + return left.lineRange.upperBound > right.lineRange.upperBound + } + + var blocks: [ChatMarkdownBlock] = [] + var proseStart = 0 + + func appendProse(until end: Int) { + guard proseStart < end else { return } + blocks.append(contentsOf: self.proseOnly(Array(source.lines[proseStart..= proseStart { + appendProse(until: extraction.lineRange.lowerBound) + blocks.append(extraction.block) + proseStart = extraction.lineRange.upperBound + } + appendProse(until: source.lines.count) if blocks.count > 1, self.containsReferenceLink(document, source: source) { return self.proseOnly(source.lines) @@ -118,6 +144,89 @@ enum ChatMarkdownBlockSegmenter { return blocks } + private static func mathExtractions( + source: SourceBuffer, + document: Document, + isComplete: Bool) -> MathExtractionResult + { + guard source.markdown.contains("$$") || source.markdown.contains(#"\["#) else { + return MathExtractionResult(extractions: [], protectedRanges: []) + } + var topLevelParagraphLines = Set() + var inlineCodeLines = Set() + func collectInlineCodeLines(from markup: any Markup) { + if markup is Markdown.InlineCode, let lineRange = source.lineRange(for: markup.range) { + inlineCodeLines.formUnion(lineRange) + } + for child in markup.children { + collectInlineCodeLines(from: child) + } + } + for child in document.children where child is Markdown.Paragraph { + if let lineRange = source.lineRange(for: child.range) { + topLevelParagraphLines.formUnion(lineRange) + } + collectInlineCodeLines(from: child) + } + var extractions: [Extraction] = [] + var protectedRanges: [Range] = [] + var lineIndex = 0 + + while lineIndex < source.lines.count { + guard topLevelParagraphLines.contains(lineIndex), + !inlineCodeLines.contains(lineIndex), + let opener = MathDelimiter.parse(source.lines[lineIndex]) + else { + lineIndex += 1 + continue + } + + if let sameLineLatex = opener.sameLineLatex { + let lineRange = lineIndex..<(lineIndex + 1) + if sameLineLatex.utf8.count <= self.maxMathBytes { + extractions.append(Extraction( + lineRange: lineRange, + block: .math(ChatMathBlock(latex: sameLineLatex, isComplete: true)))) + } else { + protectedRanges.append(lineRange) + } + lineIndex += 1 + continue + } + + let contentStart = lineIndex + 1 + var closeIndex = contentStart + while closeIndex < source.lines.count, + !opener.isClose(source.lines[closeIndex]) + { + closeIndex += 1 + } + + let closed = closeIndex < source.lines.count + guard closed || isComplete else { + // The first unmatched opener owns the remaining stream. Stop + // here so later opener-looking lines do not trigger rescans. + protectedRanges.append(lineIndex.. [ChatMarkdownBlock] { // Boundary blank lines only separate extracted blocks; the rendered // VStack provides that spacing. Interior blanks remain paragraphs. @@ -150,6 +259,52 @@ enum ChatMarkdownBlockSegmenter { code.hasSuffix("\n") ? String(code.dropLast()) : code } + private struct Extraction { + let lineRange: Range + let block: ChatMarkdownBlock + } + + private struct MathExtractionResult { + let extractions: [Extraction] + /// Rejected math stays prose and owns any block-looking syntax inside its span. + let protectedRanges: [Range] + } + + private struct MathDelimiter { + let close: String + let sameLineLatex: String? + + static func parse(_ line: String) -> MathDelimiter? { + let (indent, afterIndent) = FenceOpener.leadingSpaces(of: line) + guard indent <= 3, afterIndent < line.endIndex else { return nil } + let suffix = line[afterIndent...] + let pair: (open: String, close: String) + if suffix.hasPrefix("$$") { + pair = ("$$", "$$") + } else if suffix.hasPrefix(#"\["#) { + pair = (#"\["#, #"\]"#) + } else { + return nil + } + + let contentStart = suffix.index(suffix.startIndex, offsetBy: pair.open.count) + let remainder = suffix[contentStart...] + if remainder.trimmingCharacters(in: .whitespaces).isEmpty { + return MathDelimiter(close: pair.close, sameLineLatex: nil) + } + guard let closeRange = remainder.range(of: pair.close, options: .backwards), + remainder[closeRange.upperBound...].trimmingCharacters(in: .whitespaces).isEmpty + else { return nil } + let latex = remainder[.. Bool { + line.trimmingCharacters(in: .whitespaces) == self.close + } + } + private static func table( _ table: Markdown.Table, source: SourceBuffer, @@ -324,7 +479,7 @@ enum ChatMarkdownBlockSegmenter { return count >= self.count && line[cursor...].allSatisfy(\.isWhitespace) } - private static func leadingSpaces(of line: String) -> (count: Int, end: String.Index) { + fileprivate static func leadingSpaces(of line: String) -> (count: Int, end: String.Index) { var count = 0 var cursor = line.startIndex while cursor < line.endIndex, line[cursor] == " " { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockViews.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockViews.swift index 77aa6d585238..0e60b84e375d 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockViews.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockViews.swift @@ -1,4 +1,10 @@ +import SwiftMath import SwiftUI +#if os(macOS) +import AppKit +#else +import UIKit +#endif @MainActor struct ChatCodeBlockView: View { @@ -39,6 +45,157 @@ struct ChatCodeBlockView: View { } } +@MainActor +struct ChatMathBlockView: View { + let block: ChatMathBlock + let textColor: Color + + @ScaledMetric(relativeTo: .body) private var fontSize: CGFloat = OpenClawChatTypography.bodySize + + var body: some View { + if self.block.isComplete, + ChatMathParseCache.mathList(latex: self.block.latex) != nil + { + ScrollView(.horizontal, showsIndicators: false) { + ChatMathPlatformView( + latex: self.block.latex, + fontSize: self.fontSize, + textColor: self.textColor) + .fixedSize() + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(self.block.latex)) + } + .defaultScrollAnchor(.center) + .frame(maxWidth: .infinity) + .padding(.vertical, 4) + } else { + ChatCodeBlockView(block: ChatCodeBlock( + language: nil, + code: self.block.latex, + isComplete: false)) + } + } +} + +/// Parsed math is stable after its delimiter closes. A bounded cache avoids +/// repeating SwiftMath parsing as later streaming deltas rerender old blocks. +@MainActor +private enum ChatMathParseCache { + private enum Result { + case parsed(MTMathList) + case invalid + } + + private static var cache: [String: Result] = [:] + private static let capacity = 80 + private static let maxNestingDepth = 64 + private static let maxCommandCount = 128 + private static let unsafeCommands = [#"\color"#, #"\colorbox"#, #"\textcolor"#] + + static func mathList(latex: String) -> MTMathList? { + guard !latex.isEmpty else { return nil } + // SwiftMath silently drops unsupported Unicode instead of reporting a + // parse error. Preserve the source through the raw-text fallback. + guard latex.unicodeScalars.allSatisfy(\.isASCII) else { return nil } + // SwiftMath recursively parses groups. Bound hostile nesting before + // entering the dependency so a short expression cannot exhaust stack. + guard self.isWithinParserLimits(latex) else { return nil } + // SwiftMath 1.7.3 traps while typesetting empty color-command bodies. + // Chat owns the surrounding color, so preserve these as raw source. + guard !self.unsafeCommands.contains(where: latex.contains) else { return nil } + if let hit = self.cache[latex] { + if case let .parsed(mathList) = hit { return mathList } + return nil + } + + let result = MTMathListBuilder.build(fromString: latex) + .map(Result.parsed) ?? .invalid + if self.cache.count >= self.capacity { + self.cache.removeAll(keepingCapacity: true) + } + self.cache[latex] = result + if case let .parsed(mathList) = result { return mathList } + return nil + } + + private static func isWithinParserLimits(_ latex: String) -> Bool { + var depth = 0 + var commandCount = 0 + var escaped = false + for character in latex { + if escaped { + escaped = false + continue + } + if character == "\\" { + commandCount += 1 + if commandCount > self.maxCommandCount { return false } + escaped = true + } else if character == "{" { + depth += 1 + if depth > self.maxNestingDepth { return false } + } else if character == "}" { + depth = max(0, depth - 1) + } + } + return true + } +} + +#if os(macOS) +@MainActor +private struct ChatMathPlatformView: NSViewRepresentable { + let latex: String + let fontSize: CGFloat + let textColor: Color + + func makeNSView(context: Context) -> MTMathUILabel { + MTMathUILabel() + } + + func updateNSView(_ view: MTMathUILabel, context: Context) { + self.configure(view) + } + + private func configure(_ view: MTMathUILabel) { + view.displayErrorInline = false + view.labelMode = .display + view.textAlignment = .center + view.fontSize = self.fontSize + view.textColor = NSColor(self.textColor) + if view.latex != self.latex { + view.latex = self.latex + } + } +} +#else +@MainActor +private struct ChatMathPlatformView: UIViewRepresentable { + let latex: String + let fontSize: CGFloat + let textColor: Color + + func makeUIView(context: Context) -> MTMathUILabel { + MTMathUILabel() + } + + func updateUIView(_ view: MTMathUILabel, context: Context) { + self.configure(view) + } + + private func configure(_ view: MTMathUILabel) { + view.displayErrorInline = false + view.labelMode = .display + view.textAlignment = .center + view.fontSize = self.fontSize + view.textColor = UIColor(self.textColor) + if view.latex != self.latex { + view.latex = self.latex + } + } +} +#endif + @MainActor struct ChatMarkdownTableView: View { let table: ChatMarkdownTable diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift index 872c7d245815..4358a1cfeb9f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift @@ -51,6 +51,8 @@ struct ChatMarkdownRenderer: View { .lineSpacing(self.variant == .compact ? 2 : 4) case let .code(code): ChatCodeBlockView(block: code) + case let .math(math): + ChatMathBlockView(block: math, textColor: self.textColor) case let .table(table): ChatMarkdownTableView(table: table) } @@ -68,7 +70,7 @@ struct ChatMarkdownRenderer: View { } } -/// Fenced code and GFM tables are split out by `ChatMarkdownBlockSegmenter` +/// Fenced code, display math, and GFM tables are split out by `ChatMarkdownBlockSegmenter` /// before this runs, so prose only needs chat-style soft-break preservation. enum ChatMarkdownDisplayPreprocessor { static func preserveChatSoftBreaks(in markdown: String) -> String { diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTypography.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTypography.swift index bca9eee14fde..bf1438042334 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTypography.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTypography.swift @@ -5,6 +5,8 @@ import AppKit #endif enum OpenClawChatTypography { + static let bodySize: CGFloat = 17 + static var title3: Font { display(size: 22, weight: .bold, relativeTo: .title2) } @@ -22,7 +24,7 @@ enum OpenClawChatTypography { } static var body: Font { - body(size: 17, weight: .regular, relativeTo: .body) + body(size: self.bodySize, weight: .regular, relativeTo: .body) } static var footnote: Font { diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift index 108f356b8786..cb4bf4cfd099 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift @@ -115,6 +115,107 @@ struct ChatMarkdownBlockSegmenterTests { ]) } + // MARK: - Display math + + @Test func `same line dollar delimiters extract display math`() { + let blocks = self.segments("before\n$$ x^2 + y^2 $$\nafter") + #expect(blocks == [ + .prose("before"), + .math(ChatMathBlock(latex: "x^2 + y^2", isComplete: true)), + .prose("after"), + ]) + } + + @Test func `own line dollar delimiters extract multiline display math`() { + let blocks = self.segments(""" + $$ + \\begin{aligned} + x &= 1 \\\\ + y &= 2 + \\end{aligned} + $$ + """) + #expect(blocks == [ + .math(ChatMathBlock( + latex: "\\begin{aligned}\nx &= 1 \\\\\ny &= 2\n\\end{aligned}", + isComplete: true)), + ]) + } + + @Test func `bracket delimiters extract display math`() { + let blocks = self.segments(#"\[\frac{a}{b}\]"#) + #expect(blocks == [ + .math(ChatMathBlock(latex: #"\frac{a}{b}"#, isComplete: true)), + ]) + } + + @Test func `inline math delimiters stay prose`() { + let markdown = #"single $x$, parenthesized \(y\), and prose around $$z$$"# + #expect(self.segments(markdown) == [.prose(markdown)]) + } + + @Test func `unclosed math while streaming stays prose`() { + let markdown = "$$\nx + y" + #expect(self.segments(markdown, isComplete: false) == [.prose(markdown)]) + } + + @Test func `unclosed math in complete message renders as math`() { + let blocks = self.segments("$$\nx + y") + #expect(blocks == [ + .math(ChatMathBlock(latex: "x + y", isComplete: true)), + ]) + } + + @Test func `math composes with prose and fenced code blocks`() { + let blocks = self.segments("before\n$$E = mc^2$$\n```swift\nlet value = 1\n```\nafter") + #expect(blocks == [ + .prose("before"), + .math(ChatMathBlock(latex: "E = mc^2", isComplete: true)), + .code(ChatCodeBlock(language: "swift", code: "let value = 1", isComplete: true)), + .prose("after"), + ]) + } + + @Test func `oversized math stays raw prose`() { + let latex = String(repeating: "x", count: ChatMarkdownBlockSegmenter.maxMathBytes + 1) + let markdown = "$$\n\(latex)\n$$" + #expect(self.segments(markdown) == [.prose(markdown)]) + } + + @Test func `oversized math keeps nested code fence in prose`() { + let latex = String(repeating: "x", count: ChatMarkdownBlockSegmenter.maxMathBytes + 1) + let markdown = "$$\n\(latex)\n```swift\nlet value = 1\n```\n$$" + #expect(self.segments(markdown) == [.prose(markdown)]) + } + + @Test func `math delimiters inside code fences stay code`() { + let code = "$x$\n$$\nx + y\n$$\n\\[z\\]" + let blocks = self.segments("```tex\n\(code)\n```") + #expect(blocks == [ + .code(ChatCodeBlock(language: "tex", code: code, isComplete: true)), + ]) + } + + @Test func `math delimiters inside multiline code span stay prose`() { + let markdown = "`literal\n$$ x + y $$\nend`" + #expect(self.segments(markdown) == [.prose(markdown)]) + } + + @Test func `math delimiters inside list stay prose`() { + let markdown = "- item\n $$x + y$$" + #expect(self.segments(markdown) == [.prose(markdown)]) + } + + @Test func `unclosed streaming math leaves later opener lines as prose`() { + let markdown = "\\[\nfirst\n\\[\nsecond" + #expect(self.segments(markdown, isComplete: false) == [.prose(markdown)]) + } + + @Test func `unclosed streaming math keeps later code fence in prose`() { + let markdown = "before\n$$\nx + y\n```swift\nlet value = 1\n```" + #expect(self.segments(markdown, isComplete: false) == [.prose(markdown)]) + } + // MARK: - Streaming fallbacks @Test func `unclosed fence while streaming stays plain`() {