import Foundation /// Structured pieces extracted from a post body during rendering. public struct OrgBlock: Sendable, Hashable { public enum Kind: Sendable, Hashable { case src(language: String?) case quote case example } public var kind: Kind public var content: String public init(kind: Kind, content: String) { self.kind = kind self.content = content } } public struct OrgMention: Sendable, Hashable { public var nick: String public var feedURL: URL public init(nick: String, feedURL: URL) { self.nick = nick self.feedURL = feedURL } } public struct RenderedBody: Sendable { public var inline: AttributedString public var blocks: [OrgBlock] public var mentions: [OrgMention] public var imageURLs: [URL] public init( inline: AttributedString, blocks: [OrgBlock] = [], mentions: [OrgMention] = [], imageURLs: [URL] = [] ) { self.inline = inline self.blocks = blocks self.mentions = mentions self.imageURLs = imageURLs } } /// Turns an Org Social post body into a SwiftUI-ready `AttributedString` plus /// side-car lists of referenced images, mentions, and source/quote/example /// blocks. /// /// Scope intentionally ignores lists and nesting. Supported inline markers: /// - `*bold*` → `.stronglyEmphasized` /// - `/italic/` → `.emphasized` /// - `~code~`, `=verbatim=` → `.code` /// - `+strike+` → `.strikethrough` /// - `_underline_` is **not** rendered with an attribute (Foundation's /// `InlinePresentationIntent` has no underline case; we keep the markers /// visible as `_text_` rather than linking against UIKit/AppKit just for /// one attribute). /// /// Also supported: /// - Bracket links `[[URL][label]]` and `[[URL]]`. /// - Image extraction for bracketed links whose URL ends in a known image /// extension. /// - Mention extraction for `[[org-social:URL][nick]]`. /// - Bare http/https URLs in free text. /// - Fenced blocks `#+BEGIN_SRC`, `#+BEGIN_QUOTE`, `#+BEGIN_EXAMPLE` /// (content NOT parsed for emphasis; extracted for separate rendering). public enum OrgBodyRenderer { public static func render(_ body: String) -> RenderedBody { var remaining = body let blocks = extractBlocks(from: &remaining) let mentions = extractMentions(from: &remaining) let images = extractImages(from: &remaining) let trimmed = remaining.trimmingCharacters(in: .whitespacesAndNewlines) var inline = buildInline(from: trimmed) // After inline formatting is applied, decorate each `@nick` // occurrence with a tappable link to the mentioned feed URL and an // accent colour so the UI can navigate on tap (handled by the // view's `OpenURLAction`). Done last so earlier markup passes // don't strip the attributes. annotateMentionLinks(in: &inline, mentions: mentions) return RenderedBody(inline: inline, blocks: blocks, mentions: mentions, imageURLs: images) } private static func annotateMentionLinks(in attributed: inout AttributedString, mentions: [OrgMention]) { // `link` makes the span tappable; SwiftUI renders it in the accent // colour automatically. No need to import SwiftUI from the lib. // // AttributedString's `range(of:)` only finds the first occurrence // so we walk the backing String view to collect all ranges, then // map them back via character offsets. for mention in mentions { let token = "@\(mention.nick)" let text = String(attributed.characters) var cursor = text.startIndex while cursor < text.endIndex, let match = text.range(of: token, range: cursor.. = [ "png", "jpg", "jpeg", "gif", "webp", "heic" ] // MARK: - Blocks private static func extractBlocks(from text: inout String) -> [OrgBlock] { let pattern = #"(?ms)^[ \t]*#\+BEGIN_(SRC|QUOTE|EXAMPLE)(?:[ \t]+([^\n]*))?\n(.*?)\n[ \t]*#\+END_\1[ \t]*$"# guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else { return [] } let ns = text as NSString let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length)) let blocks: [OrgBlock] = matches.map { m in let kindStr = ns.substring(with: m.range(at: 1)).uppercased() let langRange = m.range(at: 2) let content = ns.substring(with: m.range(at: 3)) let lang: String? = { guard langRange.location != NSNotFound else { return nil } let s = ns.substring(with: langRange).trimmingCharacters(in: .whitespaces) return s.isEmpty ? nil : s }() switch kindStr { case "SRC": return OrgBlock(kind: .src(language: lang), content: content) case "QUOTE": return OrgBlock(kind: .quote, content: content) default: return OrgBlock(kind: .example, content: content) } } for m in matches.reversed() { text = (text as NSString).replacingCharacters(in: m.range, with: "") } return blocks } // MARK: - Mentions private static func extractMentions(from text: inout String) -> [OrgMention] { let pattern = #"\[\[org-social:([^\]]+)\]\[([^\]]+)\]\]"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } let ns = text as NSString let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length)) var mentions: [OrgMention] = [] var seen: Set = [] for m in matches { let urlStr = ns.substring(with: m.range(at: 1)) let nick = ns.substring(with: m.range(at: 2)) guard let url = URL(string: urlStr) else { continue } if seen.insert(url).inserted { mentions.append(OrgMention(nick: nick, feedURL: url)) } } // Replace each mention link in the body text with the inline `@nick` // short form. This way the sentence reads as written ("Que tal // @org-social") instead of being fragmented into prose + a detached // chip. The side-car mentions list is still returned so callers that // need it (e.g. the mention-aware visibility filter, tap-to-profile // affordances) keep working. for m in matches.reversed() { let nick = ns.substring(with: m.range(at: 2)) text = (text as NSString).replacingCharacters(in: m.range, with: "@\(nick)") } return mentions } // MARK: - Images private static func extractImages(from text: inout String) -> [URL] { var images: [URL] = [] // [[URL][label]] whose URL is an image. let labeledPattern = #"\[\[([^\]]+)\]\[([^\]]+)\]\]"# if let rx = try? NSRegularExpression(pattern: labeledPattern) { let ns = text as NSString let matches = rx.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length)) for m in matches { let urlStr = ns.substring(with: m.range(at: 1)) if let url = URL(string: urlStr), imageExtensions.contains(url.pathExtension.lowercased()) { images.append(url) } } for m in matches.reversed() { let urlStr = (text as NSString).substring(with: m.range(at: 1)) guard let url = URL(string: urlStr), imageExtensions.contains(url.pathExtension.lowercased()) else { continue } text = (text as NSString).replacingCharacters(in: m.range, with: "") } } // [[URL]] bare whose URL is an image. let barePattern = #"\[\[([^\]\[]+)\]\]"# if let rx = try? NSRegularExpression(pattern: barePattern) { let ns = text as NSString let matches = rx.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length)) for m in matches { let urlStr = ns.substring(with: m.range(at: 1)) if let url = URL(string: urlStr), imageExtensions.contains(url.pathExtension.lowercased()) { images.append(url) } } for m in matches.reversed() { let urlStr = (text as NSString).substring(with: m.range(at: 1)) guard let url = URL(string: urlStr), imageExtensions.contains(url.pathExtension.lowercased()) else { continue } text = (text as NSString).replacingCharacters(in: m.range, with: "") } } return images } // MARK: - Inline: bracket links + emphasis + bare URLs private static func buildInline(from text: String) -> AttributedString { // Split the text into plain segments and labeled-link segments first, // so emphasis rules don't collide with bracketed URLs and we can keep // the link target on the displayed label. struct Segment { let text: String; let url: URL? } var segments: [Segment] = [] let linkPattern = #"\[\[([^\]\[]+)\](?:\[([^\]]+)\])?\]"# if let rx = try? NSRegularExpression(pattern: linkPattern) { let ns = text as NSString let matches = rx.matches(in: text, options: [], range: NSRange(location: 0, length: ns.length)) var cursor = 0 for m in matches { if m.range.location > cursor { let plain = ns.substring(with: NSRange(location: cursor, length: m.range.location - cursor)) segments.append(Segment(text: plain, url: nil)) } let urlStr = ns.substring(with: m.range(at: 1)) let url = URL(string: urlStr) let labelRange = m.range(at: 2) let label = labelRange.location != NSNotFound ? ns.substring(with: labelRange) : urlStr segments.append(Segment(text: label, url: url)) cursor = m.range.location + m.range.length } if cursor < ns.length { segments.append(Segment(text: ns.substring(with: NSRange(location: cursor, length: ns.length - cursor)), url: nil)) } } else { segments = [Segment(text: text, url: nil)] } var result = AttributedString() for seg in segments { if let url = seg.url { var piece = AttributedString(seg.text) piece.link = url result.append(piece) } else { result.append(applyEmphasisAndBareURLs(to: seg.text)) } } return result } // MARK: - Emphasis + bare URLs private static func applyEmphasisAndBareURLs(to text: String) -> AttributedString { guard !text.isEmpty else { return AttributedString() } var working = AttributedString(text) for marker in Self.markers { working = applyMarker(working: working, pattern: marker.pattern, apply: marker.apply) } return applyBareURLs(working: working) } private static let markers: [(pattern: String, apply: @Sendable (inout AttributedString) -> Void)] = [ emphasisPattern(#"\*"#) { $0.inlinePresentationIntent = .stronglyEmphasized }, emphasisPattern(#"/"#) { $0.inlinePresentationIntent = .emphasized }, emphasisPattern(#"\+"#) { $0.inlinePresentationIntent = .strikethrough }, emphasisPattern(#"~"#) { $0.inlinePresentationIntent = .code }, emphasisPattern(#"="#) { $0.inlinePresentationIntent = .code } ] /// Builds the Org Mode-ish emphasis regex for a given marker. /// /// Opening context: start of string or one of `\s ( { [ " '`. /// Closing context: end of string or one of `\s ) } ] " ' . , ; : ! ? -`. /// Content: non-whitespace at both ends, no markers or newlines inside. /// /// Curly quotes (U+2018/U+2019) are not in the context class; an author /// using curly quotes around an emphasised word won't get it emphasised. /// Acceptable trade-off for avoiding Unicode escapes in the regex. private static func emphasisPattern( _ markerEscaped: String, apply: @escaping @Sendable (inout AttributedString) -> Void ) -> (pattern: String, apply: @Sendable (inout AttributedString) -> Void) { let pre = #"(^|[\s({\["'])"# let post = #"(?=$|[\s)}\]"'\.,;:!?\-])"# let content = #"([^\s"# + markerEscaped + #"\n](?:[^"# + markerEscaped + #"\n]*[^\s"# + markerEscaped + #"\n])?)"# return (pre + markerEscaped + content + markerEscaped + post, apply) } private static func applyMarker( working: AttributedString, pattern: String, apply: @Sendable (inout AttributedString) -> Void ) -> AttributedString { let plain = String(working.characters) guard let regex = try? NSRegularExpression(pattern: pattern) else { return working } let ns = plain as NSString let matches = regex.matches(in: plain, options: [], range: NSRange(location: 0, length: ns.length)) guard !matches.isEmpty else { return working } var rebuilt = AttributedString() var last = 0 for m in matches { let prefixRange = m.range(at: 1) let contentRange = m.range(at: 2) let outerStart = m.range.location let outerEnd = m.range.location + m.range.length if outerStart > last { rebuilt.append(slice(working: working, plain: plain, nsRange: NSRange(location: last, length: outerStart - last))) } if prefixRange.length > 0 { rebuilt.append(slice(working: working, plain: plain, nsRange: prefixRange)) } var piece = slice(working: working, plain: plain, nsRange: contentRange) apply(&piece) rebuilt.append(piece) last = outerEnd } if last < ns.length { rebuilt.append(slice(working: working, plain: plain, nsRange: NSRange(location: last, length: ns.length - last))) } return rebuilt } private static func applyBareURLs(working: AttributedString) -> AttributedString { let plain = String(working.characters) let pattern = #"https?://[^\s\)\"'\]<>]*[^\s\)\"'\]<>.,;:!?]"# guard let regex = try? NSRegularExpression(pattern: pattern) else { return working } let ns = plain as NSString let matches = regex.matches(in: plain, options: [], range: NSRange(location: 0, length: ns.length)) guard !matches.isEmpty else { return working } var rebuilt = AttributedString() var last = 0 for m in matches { if m.range.location > last { rebuilt.append(slice(working: working, plain: plain, nsRange: NSRange(location: last, length: m.range.location - last))) } var piece = slice(working: working, plain: plain, nsRange: m.range) if let url = URL(string: ns.substring(with: m.range)) { piece.link = url } rebuilt.append(piece) last = m.range.location + m.range.length } if last < ns.length { rebuilt.append(slice(working: working, plain: plain, nsRange: NSRange(location: last, length: ns.length - last))) } return rebuilt } // MARK: - Slicing private static func slice( working: AttributedString, plain: String, nsRange: NSRange ) -> AttributedString { guard let range = Range(nsRange, in: plain) else { return AttributedString() } let startOffset = plain.distance(from: plain.startIndex, to: range.lowerBound) let endOffset = plain.distance(from: plain.startIndex, to: range.upperBound) let startIdx = working.characters.index(working.characters.startIndex, offsetBy: startOffset) let endIdx = working.characters.index(working.characters.startIndex, offsetBy: endOffset) return AttributedString(working[startIdx..