diff --git a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/AST.swift b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/AST.swift index ba39bd54..26a63fb5 100644 --- a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/AST.swift +++ b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/AST.swift @@ -140,7 +140,7 @@ public enum AST { public let children: [ASTNode] public let sourceLocation: SourceLocation? - /// Heading level (1-6) + /// Heading level (1-3) public let level: Int public init( @@ -177,6 +177,9 @@ public enum AST { /// Starting number for ordered lists public let startNumber: Int? + + /// Nesting level + public let level: Int /// List items public let items: [ASTNode] @@ -184,11 +187,13 @@ public enum AST { public init( isOrdered: Bool, startNumber: Int? = nil, + level: Int, items: [ASTNode], sourceLocation: SourceLocation? = nil ) { self.isOrdered = isOrdered self.startNumber = startNumber + self.level = level self.items = items self.sourceLocation = sourceLocation self.children = items @@ -200,8 +205,12 @@ public enum AST { public let nodeType: ASTNodeType = .listItem public let children: [ASTNode] public let sourceLocation: SourceLocation? + + /// Number of the item in the ordered list + public let listNumber: Int? - public init(children: [ASTNode], sourceLocation: SourceLocation? = nil) { + public init(itemNumber: Int? = nil, children: [ASTNode], sourceLocation: SourceLocation? = nil) { + self.listNumber = itemNumber self.children = children self.sourceLocation = sourceLocation } diff --git a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/BlockParser.swift b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/BlockParser.swift index 5e198404..b8deafc9 100644 --- a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/BlockParser.swift +++ b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/BlockParser.swift @@ -428,22 +428,29 @@ public final class BlockParser { // MARK: - List Parsers - private func parseList() throws -> AST.ListNode { + private func parseList(_ indentationLevel: Int = 0) throws -> AST.ListNode { let startLocation = tokenStream.current.location let firstMarker = tokenStream.current.content - let isOrdered = firstMarker.last == "." || firstMarker.last == ")" + let isOrdered = firstMarker.last == "." let startNumber = isOrdered ? Int(firstMarker.dropLast()) : nil let delimiter = isOrdered ? firstMarker.last : nil let bulletChar = isOrdered ? nil : firstMarker.first var items: [ASTNode] = [] + var whitespaceCount = 0 + var itemNumber = startNumber while !tokenStream.isAtEnd && tokenStream.check(.listMarker) { + let level = whitespaceCount / 2 + if whitespaceCount != 0 && level < indentationLevel { + print(tokenStream.current.content) + break + } let marker = tokenStream.current.content // Check if this marker matches the list type - let markerIsOrdered = marker.last == "." || marker.last == ")" + let markerIsOrdered = marker.last == "." if markerIsOrdered != isOrdered { break } @@ -459,21 +466,31 @@ public final class BlockParser { } // Parse list item - let item = try parseListItem() + var item: ASTNode + if whitespaceCount > 0 && level != indentationLevel { + item = try parseList(indentationLevel == level ? indentationLevel : indentationLevel + 1) + } else { + item = try parseListItem(itemNumber) + } items.append(item) + + if isOrdered { + itemNumber! += 1 + } - skipWhitespaceAndNewlines() + whitespaceCount = skipWhitespaceAndNewlines() } return AST.ListNode( isOrdered: isOrdered, startNumber: startNumber, + level: indentationLevel, items: items, sourceLocation: startLocation ) } - private func parseListItem() throws -> AST.ListItemNode { + private func parseListItem(_ itemNumber: Int?) throws -> AST.ListItemNode { let startLocation = tokenStream.current.location // Consume list marker @@ -525,7 +542,7 @@ public final class BlockParser { } } - return AST.ListItemNode(children: children, sourceLocation: startLocation) + return AST.ListItemNode(itemNumber: itemNumber, children: children, sourceLocation: startLocation) } private func isNextListItem() -> Bool { @@ -859,9 +876,14 @@ public final class BlockParser { } } - private func skipWhitespaceAndNewlines() { + private func skipWhitespaceAndNewlines() -> Int { + var count: Int = 0 while tokenStream.check(.whitespace) || tokenStream.check(.newline) { + if tokenStream.check(.whitespace) { + count += tokenStream.current.length + } tokenStream.advance() } + return count } } diff --git a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/CommonMarkExtensions.swift b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/CommonMarkExtensions.swift index 5f8ac0e0..f8407346 100644 --- a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/CommonMarkExtensions.swift +++ b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/CommonMarkExtensions.swift @@ -183,7 +183,7 @@ public enum CommonMarkUtils { // Must be followed by . or ) guard index < trimmed.endIndex else { return nil } let delimiter = trimmed[index] - guard delimiter == "." || delimiter == ")" else { return nil } + guard delimiter == "." else { return nil } index = trimmed.index(after: index) @@ -242,7 +242,7 @@ public struct ListMarkerInfo: Sendable, Equatable { /// The number for ordered lists public let number: Int? - /// The delimiter for ordered lists ('.' or ')') + /// The delimiter for ordered lists ('.') public let delimiter: Character? /// The width of the marker in characters diff --git a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/DiscordMarkdownParser.swift b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/DiscordMarkdownParser.swift index 26f7bbe7..80945876 100644 --- a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/DiscordMarkdownParser.swift +++ b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/DiscordMarkdownParser.swift @@ -263,6 +263,7 @@ public final class DiscordMarkdownParser: Sendable { return AST.ListNode( isOrdered: list.isOrdered, startNumber: list.startNumber, + level: list.level, items: processedItems, sourceLocation: list.sourceLocation ) @@ -279,6 +280,7 @@ public final class DiscordMarkdownParser: Sendable { ) return AST.ListItemNode( + itemNumber: listItem.listNumber, children: processedChildren, sourceLocation: listItem.sourceLocation ) @@ -480,6 +482,7 @@ public final class DiscordMarkdownParser: Sendable { case .listItem: if let listItemNode = originalNode as? AST.ListItemNode { return AST.ListItemNode( + itemNumber: listItemNode.listNumber, children: children, sourceLocation: listItemNode.sourceLocation ) diff --git a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/Tokenizer.swift b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/Tokenizer.swift index 2650069e..81b0ac48 100644 --- a/DiscordMarkdownParser/Sources/DiscordMarkdownParser/Tokenizer.swift +++ b/DiscordMarkdownParser/Sources/DiscordMarkdownParser/Tokenizer.swift @@ -559,7 +559,7 @@ public final class MarkdownTokenizer { let startLocation = currentLocation var content = "" - while !isAtEnd && currentChar == "#" && content.count < 6 { + while !isAtEnd && currentChar == "#" && content.count < 3 { content.append(currentChar) advance() } diff --git a/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift b/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift index 18303902..90f34464 100644 --- a/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift +++ b/Paicord/Common/Chat/Messages/Message Body/Markdown/MarkdownText.swift @@ -219,13 +219,10 @@ struct MarkdownText: View, Equatable { if let children = block.children { VStack(alignment: .leading, spacing: 4) { ForEach(children) { child in - HStack(alignment: .top, spacing: 8) { - Text(verbatim: "•").font(.body) - BlockView(block: child) - .equatable() - } + BlockView(block: child) + .equatable() } - } + }.padding(.leading, CGFloat(block.level ?? 0) * AppKitOrUIKitFont.labelFontSize * 2) } case .listItem: @@ -233,10 +230,17 @@ struct MarkdownText: View, Equatable { let converted = AttributedString(attr) Text(converted) } else if let children = block.children { + let level = block.level ?? 0 + let ordered = block.isOrdered ?? false + let number = block.itemNumber ?? 1 + VStack(alignment: .leading, spacing: 4) { ForEach(children) { nested in - BlockView(block: nested) - .equatable() + HStack(alignment: .top, spacing: 8) { + Text(verbatim: ordered ? "\(number)." : level > 0 ? "◦" : "•").font(.body) + BlockView(block: nested) + .equatable() + } } } } else { @@ -330,6 +334,7 @@ private struct BlockElement: Identifiable, Equatable, Hashable { let attributedContent: NSAttributedString? let isOrdered: Bool? let startingNumber: Int? + let itemNumber: Int? let codeContent: String? let language: String? let level: Int? @@ -491,6 +496,7 @@ class MarkdownRendererVM { attributedContent: attributed, isOrdered: nil, startingNumber: nil, + itemNumber: nil, codeContent: nil, language: nil, level: nil, @@ -511,6 +517,7 @@ class MarkdownRendererVM { attributedContent: attributed, isOrdered: nil, startingNumber: nil, + itemNumber: nil, codeContent: nil, language: nil, level: heading.level, @@ -531,6 +538,7 @@ class MarkdownRendererVM { attributedContent: attributed, isOrdered: nil, startingNumber: nil, + itemNumber: nil, codeContent: nil, language: nil, level: nil, @@ -546,6 +554,7 @@ class MarkdownRendererVM { attributedContent: nil, isOrdered: nil, startingNumber: nil, + itemNumber: nil, codeContent: code.content, language: code.language, level: nil, @@ -568,6 +577,7 @@ class MarkdownRendererVM { attributedContent: nil, isOrdered: nil, startingNumber: nil, + itemNumber: nil, codeContent: nil, language: nil, level: nil, @@ -593,15 +603,20 @@ class MarkdownRendererVM { ), nodeType: .listItem, attributedContent: nil, - isOrdered: nil, - startingNumber: nil, + isOrdered: list.isOrdered, + startingNumber: list.startNumber, + itemNumber: listItem.listNumber, codeContent: nil, language: nil, - level: nil, + level: list.level, children: listItemChildren, sourceLocation: listItem.sourceLocation ) items.append(itemBlock) + } else if let nestedList = item as? AST.ListNode { + if let nestedListBlock = makeBlock(from: nestedList) { + items.append(nestedListBlock) + } } else { let attr = renderInlinesToNSAttributedString( nodes: item.children, @@ -611,11 +626,12 @@ class MarkdownRendererVM { id: makeID(base: sourceID(for: item), content: attr.string), nodeType: .listItem, attributedContent: attr, - isOrdered: nil, - startingNumber: nil, + isOrdered: list.isOrdered, + startingNumber: list.startNumber, + itemNumber: nil, codeContent: nil, language: nil, - level: nil, + level: list.level, children: nil, sourceLocation: item.sourceLocation ) @@ -626,11 +642,12 @@ class MarkdownRendererVM { id: makeID(base: baseIDSeed, content: items.map(\.id).description), nodeType: .list, attributedContent: nil, - isOrdered: nil, - startingNumber: nil, + isOrdered: list.isOrdered, + startingNumber: list.startNumber, + itemNumber: nil, codeContent: nil, language: nil, - level: nil, + level: list.level, children: items, sourceLocation: node.sourceLocation ) @@ -645,6 +662,7 @@ class MarkdownRendererVM { attributedContent: attr, isOrdered: nil, startingNumber: nil, + itemNumber: nil, codeContent: nil, language: nil, level: nil, @@ -664,6 +682,7 @@ class MarkdownRendererVM { attributedContent: attr, isOrdered: nil, startingNumber: nil, + itemNumber: nil, codeContent: nil, language: nil, level: nil, @@ -1680,3 +1699,75 @@ final class EmojiAttachmentViewProvider: NSTextAttachmentViewProvider { container = nil } } + +#Preview { + let msg = """ + 1. Item 1 + 3. Item 2 (3.) + 5. Item 2a (5.) + 2. Item 2b (2.) + 7. Item 3 (7.) + """ + let user = DiscordUser( + id: .init("381538809180848128"), + username: "markdown test", + discriminator: "0", + global_name: nil, + avatar: "df71b3f223666fd8331c9940c6f7cbd9", + banner: nil, + bot: false, + system: false, + mfa_enabled: true, + accent_color: nil, + locale: .englishUS, + verified: true, + email: nil, + flags: .init(rawValue: 4_194_352), + premium_type: nil, + public_flags: .init(rawValue: 4_194_304), + avatar_decoration_data: nil + ) + MessageCell( + for: .init( + id: try! .makeFake(), + channel_id: try! .makeFake(), + author: user, + content: msg, + timestamp: .init(date: .now), + edited_timestamp: nil, + tts: false, + mention_everyone: false, + mentions: [], + mention_roles: [], + mention_channels: nil, + attachments: [], + embeds: [], + reactions: nil, + nonce: nil, + pinned: false, + webhook_id: nil, + type: DiscordChannel.Message.Kind.default, + activity: nil, + application: nil, + application_id: nil, + message_reference: nil, + flags: [], + referenced_message: nil, + interaction: nil, + thread: nil, + components: nil, + sticker_items: nil, + stickers: nil, + position: nil, + role_subscription_data: nil, + resolved: nil, + poll: nil, + call: nil, + guild_id: nil, + member: nil + ), + prior: nil, + channel: .init(id: try! .makeFake()) + ) +} +