Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Styled markdown with AttributedString #3590

Open
wants to merge 37 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1eefa25
Add AttributedString extension for parsing markdown and enabling styl…
laevandus Feb 11, 2025
a777f20
Add tests for AttributedString markdown styling
laevandus Feb 12, 2025
d9c80e8
Use paragraph style for setting correct text alignment in lists
laevandus Feb 12, 2025
fadc96d
Add tests for inline presentation and backslash
laevandus Feb 12, 2025
8c71979
Support custom body text color
laevandus Feb 12, 2025
ea4a555
Remove MarkdownStyles.linkFont because we use UITextView for renderin…
laevandus Feb 12, 2025
923806d
Add CHANGELOG entry
laevandus Feb 12, 2025
f9d581c
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 12, 2025
e6ef338
Document linkFont removal and attributed string parsing
laevandus Feb 13, 2025
43464e0
Fix unit-tests
laevandus Feb 13, 2025
c6d2142
Fix more tests
laevandus Feb 13, 2025
9f8f284
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 13, 2025
ce6e353
Replace public extension with MarkdownParser type
laevandus Feb 13, 2025
501e9d2
Do not make headers bold
laevandus Feb 13, 2025
00f2905
Return nil for presentation intents which are not handled
laevandus Feb 17, 2025
111cb83
Update changelog
laevandus Feb 17, 2025
6314102
Update snapshot tests for backslashes
laevandus Feb 17, 2025
5978c43
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 17, 2025
3d957fc
Basic support for RTL and remove background color from code blocks
laevandus Feb 18, 2025
c70bd5e
Fix test compilation
laevandus Feb 18, 2025
8f3f59c
Use unicode bullet for lists in markdown
laevandus Feb 18, 2025
53809fa
Update changelog about iOS 15
laevandus Feb 18, 2025
9cfe3f1
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 18, 2025
58cd8e6
Be more careful with insertion index and skip parsing if string is empty
laevandus Feb 18, 2025
002f362
Use Fonts for default fonts in markdown
laevandus Feb 18, 2025
ca88af3
Allow appending when inserting
laevandus Feb 18, 2025
81b75aa
Fix a test after character change
laevandus Feb 19, 2025
468c2ed
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 19, 2025
99b5a04
Remove SwiftyMarkdown (#3591)
laevandus Feb 19, 2025
7d8edbb
Add containsMarkdown to MarkdownParser and move related tests. Handle…
laevandus Feb 19, 2025
24cfead
Merge branch 'feature/attributed-string-styled-markdown' of https://g…
laevandus Feb 19, 2025
255a863
Add empty string check
laevandus Feb 19, 2025
eb289f5
Update changelog
laevandus Feb 19, 2025
5d2c5f4
Fix mistake in test
laevandus Feb 19, 2025
65f804e
Merge branch 'develop' into feature/attributed-string-styled-markdown
laevandus Feb 19, 2025
470c972
Fix various issues with newlines and adjustments to formatting
laevandus Feb 21, 2025
6062bd6
Add more snapshot tests
laevandus Feb 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
# Upcoming

## StreamChat
### ✅ Added
- Add `MarkdownParser.style()` for parsing and styling markdown strings [#3590](https://github.com/GetStream/stream-chat-swift/pull/3590)
- Add `Fonts.title2` for supporting markdown headers [#3590](https://github.com/GetStream/stream-chat-swift/pull/3590)
### 🐞 Fixed
- Update channel's preview message when coming back to online [#3574](https://github.com/GetStream/stream-chat-swift/pull/3574)

### StreamChatUI
### 🔄 Changed
- Feature rich markdown rendering with `AttributedString` [#3590](https://github.com/GetStream/stream-chat-swift/pull/3590)
- Note: Markdown is rendered only on iOS 15 and above. On iOS 14 and below markdown is rendered as plain text.
### 💥 Removed
- Remove `MarkdownStyles.linkFont` because link attributes are ignored by `UITextView`. Update `ChatMessageContentView.textView.linkTextAttributes` instead. [#3590](https://github.com/GetStream/stream-chat-swift/pull/3590)

# [4.72.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.72.0)
_February 04, 2025_
Expand Down
199 changes: 199 additions & 0 deletions Sources/StreamChat/Utils/MarkdownParser.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Foundation

@available(iOS 15, *)
/// A parser for markdown which generates a styled attributed string.
public enum MarkdownParser {
/// Creates an attributed string from a Markdown-formatted string using the provided style attributes.
///
/// Apple's markdown initialiser parses markdown and adds ``NSPresentationIntent`` and ``NSInlinePresentationIntent``
/// attributes and does not do any newline handling. All the styling related attributes (font, foregroundColor etc)
/// and newline handling, needs to be implemented separately.
/// UIKit and SwiftUI support different ``AttributedString`` attributes (see ``AttributeScopes.SwiftUIAttributes``,
/// ``AttributeScopes.UIKitAttributes``, and ``AttributeScopes.FoundationAttributes``). The latter is shared by both.
/// Therefore, we need additional parsing for presentation intent attributes and add respective style related attributes.
///
/// Here is an example of a nested list which shows why we need to do such handling below (note how parent
/// lists also show up).
/// ```
/// List item 1 {
/// NSPresentationIntent = [paragraph (id 3), listItem 1 (id 2), unorderedList (id 1)]
/// }
/// Nested item which is very very long and keeps going until it is wrapped {
/// NSPresentationIntent = [paragraph (id 6), listItem 1 (id 5), unorderedList (id 4), listItem 1 (id 2), unorderedList (id 1)]
/// }
/// Another nested item which is very very long and keeps going until it is wrapped {
/// NSPresentationIntent = [paragraph (id 9), listItem 1 (id 8), unorderedList (id 7), listItem 1 (id 5), unorderedList (id 4), listItem 1 (id 2), unorderedList (id 1)]
/// }
/// ```
///
/// - Parameters:
/// - markdown: The string that contains the Markdown formatting.
/// - attributes: The attributes to use for the whole string.
/// - options: Options that affect how the Markdown string is parsed and styled.
/// - inlinePresentationIntentAttributes: The closure for customising attributes for inline presentation intents.
/// - presentationIntentAttributes: The closure for customising attributes for presentation intents. Called for quote, code, list item, and headers.
public static func style(
markdown: String,
options: ParsingOptions,
attributes: AttributeContainer,
inlinePresentationIntentAttributes: (InlinePresentationIntent) -> AttributeContainer?,
presentationIntentAttributes: (PresentationIntent.Kind, PresentationIntent) -> AttributeContainer?
) throws -> AttributedString {
var attributedString = try AttributedString(
markdown: markdown,
options: AttributedString.MarkdownParsingOptions(
allowsExtendedAttributes: true,
interpretedSyntax: .full,
failurePolicy: .returnPartiallyParsedIfPossible,
languageCode: nil
)
)

attributedString.mergeAttributes(attributes)

// Inline intents are handled by rendering automatically
for (inlinePresentationIntent, range) in attributedString.runs[\.inlinePresentationIntent] {
guard let inlinePresentationIntent else { continue }
guard let attributes = inlinePresentationIntentAttributes(inlinePresentationIntent) else { continue }
attributedString[range].mergeAttributes(attributes)
}

// Style block based intents
var previousBlockStyling: BlockStyling?
for (presentationIntent, range) in attributedString.runs[\.presentationIntent].reversed() {
guard let presentationIntent else { continue }
var blockStyling = BlockStyling(range: range)

for blockIntentType in presentationIntent.components {
switch blockIntentType.kind {
case .blockQuote:
blockStyling.mergedAttributes = presentationIntentAttributes(blockIntentType.kind, presentationIntent)
blockStyling.prependedString = "| "
blockStyling.succeedingNewlineCount += 1
case .codeBlock:
blockStyling.mergedAttributes = presentationIntentAttributes(blockIntentType.kind, presentationIntent)
blockStyling.precedingNewlineCount += 1
case .header:
blockStyling.mergedAttributes = presentationIntentAttributes(blockIntentType.kind, presentationIntent)
blockStyling.precedingNewlineCount += 1
blockStyling.succeedingNewlineCount += 1
case .paragraph:
blockStyling.precedingNewlineCount += 1
case .listItem(ordinal: let ordinal):
if blockStyling.listItemOrdinal == nil {
blockStyling.listItemOrdinal = ordinal
blockStyling.mergedAttributes = presentationIntentAttributes(blockIntentType.kind, presentationIntent)
}
case .orderedList:
blockStyling.listId = blockIntentType.identity
if blockStyling.isOrdered == nil {
blockStyling.isOrdered = true
} else {
blockStyling.prependedString.insert("\t", at: blockStyling.prependedString.startIndex)
}
case .unorderedList:
blockStyling.listId = blockIntentType.identity
if blockStyling.isOrdered == nil {
blockStyling.isOrdered = false
} else {
blockStyling.prependedString.insert("\t", at: blockStyling.prependedString.startIndex)
}
case .thematicBreak, .table, .tableHeaderRow, .tableRow, .tableCell:
break
@unknown default:
break
}
}
// Remove presentation intent attribute from the final string because it has been handled
attributedString[range].replaceAttributes(
AttributeContainer().presentationIntent(presentationIntent),
with: AttributeContainer()
)
// Give additional space for just text
if presentationIntent.components.count == 1, presentationIntent.components.allSatisfy({ $0.kind == .paragraph }) {
blockStyling.succeedingNewlineCount += 1
}
// Preparing list items
if let listItemOrdinal = blockStyling.listItemOrdinal {
if blockStyling.isOrdered == true {
blockStyling.prependedString.append("\(listItemOrdinal).\t")
} else {
blockStyling.prependedString.append("\u{2022}\t")
}
// Extra space when list's last item
if let previousBlockStyling, previousBlockStyling.listId != blockStyling.listId {
blockStyling.succeedingNewlineCount += 1
}
}
// Inserting additional space after the current block (reverse enumeration, therefore use the previous range)
if blockStyling.succeedingNewlineCount > 0, let previousBlockStyling {
let newlineString = String(repeating: "\n", count: blockStyling.succeedingNewlineCount)
let insertedString = AttributedString(newlineString, attributes: attributes)
attributedString.insertSafely(insertedString, at: previousBlockStyling.range.lowerBound)
}
// Additional attributes
if let attributes = blockStyling.mergedAttributes {
attributedString[range].mergeAttributes(attributes)
}
// Inserting additional characters (list items etc)
if !blockStyling.prependedString.isEmpty {
let attributes = attributes.merging(blockStyling.mergedAttributes ?? AttributeContainer())
if options.layoutDirectionLeftToRight {
let insertedString = AttributedString(blockStyling.prependedString, attributes: attributes)
attributedString.insertSafely(insertedString, at: range.lowerBound)
} else {
let insertedString = AttributedString(blockStyling.prependedString.reversed(), attributes: attributes)
attributedString.insertSafely(insertedString, at: range.upperBound)
}
}
// Spacing before the block
if blockStyling.precedingNewlineCount > 0, attributedString.startIndex != range.lowerBound {
let newlineString = String(repeating: "\n", count: blockStyling.precedingNewlineCount)
let insertedString = AttributedString(newlineString, attributes: attributes)
attributedString.insertSafely(insertedString, at: range.lowerBound)
}

previousBlockStyling = blockStyling
}

return attributedString
}
}

@available(iOS 15, *)
extension MarkdownParser {
/// Options that affect how the Markdown string is parsed and styled.
public struct ParsingOptions {
public init(layoutDirectionLeftToRight: Bool = true) {
self.layoutDirectionLeftToRight = layoutDirectionLeftToRight
}

/// Affects insertion index for additional characters like bullets and numbers for lists.
public var layoutDirectionLeftToRight = true
}
}

@available(iOS 15, *)
private extension AttributedString {
mutating func insertSafely(_ s: some AttributedStringProtocol, at index: AttributedString.Index) {
guard index >= startIndex, index < endIndex else { return }
insert(s, at: index)
}
}

// Note: newlines are used instead of paragraph style because SwiftUI does render paragraph styles
@available(iOS 15.0, *)
private struct BlockStyling {
let range: Range<AttributedString.Index>
var precedingNewlineCount = 0
var succeedingNewlineCount = 0
var mergedAttributes: AttributeContainer?
var prependedString = ""
var listItemOrdinal: Int?
var listId: Int?
var isOrdered: Bool?
}
1 change: 1 addition & 0 deletions Sources/StreamChatUI/Appearance+Fonts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public extension Appearance {
public var headline = UIFont.preferredFont(forTextStyle: .headline)
public var headlineBold = UIFont.preferredFont(forTextStyle: .headline).bold
public var title = UIFont.preferredFont(forTextStyle: .title1)
public var title2 = UIFont.preferredFont(forTextStyle: .title2)
public var title3 = UIFont.preferredFont(forTextStyle: .title3).bold
/// A font used to render emojis as "Jumbomoji".
public var emoji = UIFont.preferredFont(forTextStyle: .body).withSize(50)
Expand Down
Loading
Loading