711b46e4b7
OrgBodyRenderer now writes the feed URL into AttributedString's `link` attribute over every `@nick` occurrence after mentions have been extracted. SwiftUI renders those spans in the accent colour and makes them tappable for free. PostRowView intercepts the tap via `.environment(\.openURL, …)`: if the URL looks like an Org Social feed URL (scheme http(s) + path ending in social.org) it sets `pendingMentionURL` and a `.navigationDestination (isPresented:)` modifier pushes the matching ProfileView onto the current NavigationStack. Anything else (plain `[[https://foo]]` body links) falls through to `.systemAction` and opens normally. The existing below-the-body chips list is kept for accessibility and discoverability — inline taps are nice but the chips make every mention reachable in one glance.
384 lines
17 KiB
Swift
384 lines
17 KiB
Swift
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..<text.endIndex) {
|
|
let startOffset = text.distance(from: text.startIndex, to: match.lowerBound)
|
|
let endOffset = text.distance(from: text.startIndex, to: match.upperBound)
|
|
let attrStart = attributed.index(attributed.startIndex, offsetByCharacters: startOffset)
|
|
let attrEnd = attributed.index(attributed.startIndex, offsetByCharacters: endOffset)
|
|
attributed[attrStart..<attrEnd].link = mention.feedURL
|
|
cursor = match.upperBound
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Static data
|
|
|
|
private static let imageExtensions: Set<String> = [
|
|
"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<URL> = []
|
|
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..<endIdx])
|
|
}
|
|
}
|