Skip to content

Commit 0e4c7cd

Browse files
authored
feat(ios): render native markdown headings (#103404)
1 parent a3f9f35 commit 0e4c7cd

5 files changed

Lines changed: 155 additions & 3 deletions

File tree

apps/ios/Tests/SwiftUIRenderSmokeTests.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,32 @@ struct SwiftUIRenderSmokeTests {
165165
}
166166
}
167167

168+
@Test @MainActor func `markdown heading hierarchy builds with inline formatting and table`() {
169+
let markdown = """
170+
# First **strong** heading
171+
## Second [linked](https://example.com) heading
172+
### Third `code` heading
173+
#### Fourth heading
174+
##### Fifth heading
175+
###### Sixth heading
176+
177+
| Surface | State |
178+
| --- | --- |
179+
| iOS | Native |
180+
"""
181+
for typeSize in [DynamicTypeSize.large, .accessibility2] {
182+
let root = ChatMarkdownRenderer(
183+
text: markdown,
184+
context: .assistant,
185+
variant: .standard,
186+
font: OpenClawChatTypography.body,
187+
textColor: OpenClawChatTheme.assistantText)
188+
.environment(\.dynamicTypeSize, typeSize)
189+
190+
_ = Self.host(root, size: CGSize(width: 393, height: 700))
191+
}
192+
}
193+
168194
@Test @MainActor func `streaming assistant bubble builds mixed prose and code`() {
169195
let text = """
170196
Earlier prose stays visible.

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownBlockSegmenter.swift

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,22 @@ import Foundation
22
import Markdown
33

44
/// One renderable block of a chat message. Prose stays on the
5-
/// AttributedString pipeline; fenced code, display math, and GFM tables get dedicated views.
5+
/// AttributedString pipeline; headings, fenced code, display math, and GFM tables get dedicated views.
66
enum ChatMarkdownBlock: Equatable {
77
case prose(String)
8+
case heading(ChatMarkdownHeading)
89
case code(ChatCodeBlock)
910
case math(ChatMathBlock)
1011
case table(ChatMarkdownTable)
1112
}
1213

14+
struct ChatMarkdownHeading: Equatable {
15+
let level: Int
16+
/// Keep the complete source so Swift's Markdown parser continues to own
17+
/// inline formatting and both ATX and Setext heading syntax.
18+
let markdown: String
19+
}
20+
1321
struct ChatCodeBlock: Equatable {
1422
let language: String?
1523
let code: String
@@ -63,7 +71,7 @@ enum ChatMarkdownBlockSegmenter {
6371
static let maxTableColumns = 12
6472
static let maxTableCells = 600
6573

66-
/// Extracts only top-level fenced code, display math, and GFM tables. The parser owns
74+
/// Extracts only top-level headings, fenced code, display math, and GFM tables. The parser owns
6775
/// CommonMark container and reference semantics; nested blocks stay in the
6876
/// surrounding prose range unchanged.
6977
static func segments(markdown: String, isComplete: Bool) -> [ChatMarkdownBlock] {
@@ -81,6 +89,15 @@ enum ChatMarkdownBlockSegmenter {
8189
continue
8290
}
8391

92+
if let heading = child as? Markdown.Heading {
93+
extractions.append(Extraction(
94+
lineRange: lineRange,
95+
block: .heading(ChatMarkdownHeading(
96+
level: heading.level,
97+
markdown: source.text(in: lineRange)))))
98+
continue
99+
}
100+
84101
if let code = child as? Markdown.CodeBlock,
85102
let opener = FenceOpener.parse(source.lines[lineRange.lowerBound])
86103
{

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMarkdownRenderer.swift

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,15 @@ struct ChatMarkdownRenderer: View {
9393
.textSelection(.enabled)
9494
.lineSpacing(self.variant == .compact ? 2 : 4)
9595
.modifier(ChatInlineMathAccessibilityModifier(label: prose.inlineAccessibilityText))
96+
case let .heading(level, prose):
97+
self.proseText(prose, index: index)
98+
.font(OpenClawChatTypography.heading(level: level))
99+
.foregroundStyle(self.textColor)
100+
.tint(self.linkColor)
101+
.textSelection(.enabled)
102+
.lineSpacing(self.variant == .compact ? 2 : 4)
103+
.modifier(ChatInlineMathAccessibilityModifier(label: prose.inlineAccessibilityText))
104+
.accessibilityAddTraits(.isHeader)
96105
case let .code(code):
97106
ChatCodeBlockView(block: code)
98107
case let .math(math):
@@ -141,6 +150,13 @@ struct ChatMarkdownRenderSnapshot {
141150
markdown: markdown,
142151
isComplete: isComplete,
143152
preparesReveal: preparesReveal))
153+
case let .heading(heading):
154+
.heading(
155+
level: heading.level,
156+
prose: ChatMarkdownProse(
157+
markdown: heading.markdown,
158+
isComplete: isComplete,
159+
preparesReveal: false))
144160
case let .code(code):
145161
.code(code)
146162
case let .math(math):
@@ -164,6 +180,7 @@ struct ChatMarkdownRenderSnapshot {
164180

165181
enum ChatMarkdownRenderedBlock {
166182
case prose(ChatMarkdownProse)
183+
case heading(level: Int, prose: ChatMarkdownProse)
167184
case code(ChatCodeBlock)
168185
case math(ChatMathBlock)
169186
case table(ChatMarkdownTable)
@@ -445,7 +462,7 @@ private struct ChatInlineMathAccessibilityModifier: ViewModifier {
445462
}
446463
}
447464

448-
/// Fenced code, display math, and GFM tables are split out by `ChatMarkdownBlockSegmenter`
465+
/// Headings, fenced code, display math, and GFM tables are split out by `ChatMarkdownBlockSegmenter`
449466
/// before this runs, so prose only needs chat-style soft-break preservation.
450467
enum ChatMarkdownDisplayPreprocessor {
451468
static func preserveChatSoftBreaks(in markdown: String) -> String {

apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTypography.swift

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,23 @@ enum OpenClawChatTypography {
1919
display(size: 17, weight: .semibold, relativeTo: .headline)
2020
}
2121

22+
static func heading(level: Int) -> Font {
23+
switch level {
24+
case 1:
25+
self.display(size: 24, weight: .bold, relativeTo: .title2)
26+
case 2:
27+
self.display(size: 21, weight: .bold, relativeTo: .title3)
28+
case 3:
29+
self.display(size: 19, weight: .semibold, relativeTo: .headline)
30+
case 4:
31+
self.body(size: 17, weight: .semibold, relativeTo: .body)
32+
case 5:
33+
self.body(size: 16, weight: .semibold, relativeTo: .callout)
34+
default:
35+
self.body(size: 15, weight: .semibold, relativeTo: .subheadline)
36+
}
37+
}
38+
2239
static var callout: Font {
2340
body(size: 16, weight: .regular, relativeTo: .callout)
2441
}

apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatMarkdownBlockSegmenterTests.swift

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,81 @@ struct ChatMarkdownBlockSegmenterTests {
2626
])
2727
}
2828

29+
// MARK: - Headings
30+
31+
@Test func `all ATX heading levels become native blocks`() {
32+
for level in 1...6 {
33+
let markdown = "\(String(repeating: "#", count: level)) Heading \(level)"
34+
#expect(self.segments(markdown) == [
35+
.heading(ChatMarkdownHeading(level: level, markdown: markdown)),
36+
])
37+
}
38+
}
39+
40+
@Test func `Setext headings preserve their complete source`() {
41+
#expect(self.segments("Primary\n=======") == [
42+
.heading(ChatMarkdownHeading(level: 1, markdown: "Primary\n=======")),
43+
])
44+
#expect(self.segments("Secondary\n---------") == [
45+
.heading(ChatMarkdownHeading(level: 2, markdown: "Secondary\n---------")),
46+
])
47+
}
48+
49+
@Test func `streaming ATX heading keeps the current source`() {
50+
let markdown = "### Streamed heading"
51+
#expect(self.segments(markdown, isComplete: false) == [
52+
.heading(ChatMarkdownHeading(level: 3, markdown: markdown)),
53+
])
54+
}
55+
56+
@Test func `heading keeps inline markdown source and surrounding prose`() {
57+
let heading = "## **Status** with `code` and [docs](https://example.com) ##"
58+
#expect(self.segments("before\n\n\(heading)\n\nafter") == [
59+
.prose("before"),
60+
.heading(ChatMarkdownHeading(level: 2, markdown: heading)),
61+
.prose("after"),
62+
])
63+
}
64+
65+
@Test func `headings nested in containers stay on the prose path`() {
66+
for markdown in ["> # Quoted", "- # Listed"] {
67+
#expect(self.segments(markdown) == [.prose(markdown)])
68+
}
69+
}
70+
71+
@Test func `reference heading keeps document scoped definition`() {
72+
let markdown = "# [Docs][docs]\n\n[docs]: https://example.com"
73+
#expect(self.segments(markdown) == [.prose(markdown)])
74+
}
75+
76+
@Test func `heading composes with an unchanged native table`() {
77+
let blocks = self.segments("# Results\n\n| Name | Count |\n| --- | ---: |\n| Claw | 2 |")
78+
#expect(blocks == [
79+
.heading(ChatMarkdownHeading(level: 1, markdown: "# Results")),
80+
.table(ChatMarkdownTable(
81+
header: ["Name", "Count"],
82+
alignments: [.leading, .trailing],
83+
rows: [["Claw", "2"]])),
84+
])
85+
}
86+
87+
@Test @MainActor func `render snapshot preserves heading inline attributes`() throws {
88+
let snapshot = ChatMarkdownRenderSnapshot(
89+
text: "## **Status** and [docs](https://example.com)",
90+
isComplete: true)
91+
guard case let .heading(level, prose) = try #require(snapshot.blocks.first) else {
92+
Issue.record("expected heading block")
93+
return
94+
}
95+
96+
#expect(level == 2)
97+
#expect(String(prose.attributed.characters) == "Status and docs")
98+
#expect(prose.attributed.runs.contains { $0.link != nil })
99+
#expect(prose.attributed.runs.contains {
100+
$0.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
101+
})
102+
}
103+
29104
// MARK: - Fenced code
30105

31106
@Test func `fence with language and surrounding prose`() {

0 commit comments

Comments
 (0)