Files
andros 6f9453592a Render mentions inline as @nick instead of stripping them
Previously extractMentions removed the `[[org-social:URL][nick]]` span
from the body text entirely, leaving the sentence fragmented. A post
composed as "Que tal @org-social" displayed as "Que tal" followed by a
detached chip, which read strangely and hid the name from its context.

Now the extractor replaces each link with `@nick` inline, so the sentence
stays intact ("Que tal @org-social"). The side-car mentions array is still
populated and the chip bar below the body remains as a tappable shortcut
to the mentioned user's profile.
2026-04-24 11:04:59 +02:00

304 lines
12 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import Testing
@testable import OrgSocialKit
/// Helper: find a substring in the AttributedString and assert the attribute
/// closure returns true for every run overlapping it.
private func hasAttribute(
_ attr: AttributedString,
over substring: String,
matches: (AttributeContainer) -> Bool
) -> Bool {
let full = String(attr.characters)
guard let range = full.range(of: substring) else { return false }
let startOffset = full.distance(from: full.startIndex, to: range.lowerBound)
let endOffset = full.distance(from: full.startIndex, to: range.upperBound)
let startIdx = attr.characters.index(attr.characters.startIndex, offsetBy: startOffset)
let endIdx = attr.characters.index(attr.characters.startIndex, offsetBy: endOffset)
for run in attr[startIdx..<endIdx].runs where !matches(run.attributes) {
return false
}
return true
}
@Suite("OrgBodyRenderer inline emphasis")
struct OrgBodyInlineTests {
@Test("bold renders as stronglyEmphasized")
func bold() {
let r = OrgBodyRenderer.render("A *bold* word")
#expect(String(r.inline.characters) == "A bold word")
#expect(hasAttribute(r.inline, over: "bold") { c in
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
})
}
@Test("italic renders as emphasized")
func italic() {
let r = OrgBodyRenderer.render("An /italic/ word.")
#expect(String(r.inline.characters) == "An italic word.")
#expect(hasAttribute(r.inline, over: "italic") { c in
c.inlinePresentationIntent?.contains(.emphasized) == true
})
}
@Test("underline is kept as raw text (no attribute in v1)")
func underlineIsLiteral() {
let r = OrgBodyRenderer.render("Hello _under_ there.")
// The markers are preserved because InlinePresentationIntent has no
// underline case; we'd otherwise pull in UIKit/AppKit for one feature.
#expect(String(r.inline.characters) == "Hello _under_ there.")
}
@Test("strikethrough uses inlinePresentationIntent.strikethrough")
func strike() {
let r = OrgBodyRenderer.render("Oops +typo+!")
#expect(String(r.inline.characters) == "Oops typo!")
#expect(hasAttribute(r.inline, over: "typo") { c in
c.inlinePresentationIntent?.contains(.strikethrough) == true
})
}
@Test("code and verbatim both apply .code intent")
func codeVerbatim() {
let r = OrgBodyRenderer.render("Use ~let x~ and =NSString=.")
#expect(String(r.inline.characters) == "Use let x and NSString.")
#expect(hasAttribute(r.inline, over: "let x") { c in
c.inlinePresentationIntent?.contains(.code) == true
})
#expect(hasAttribute(r.inline, over: "NSString") { c in
c.inlinePresentationIntent?.contains(.code) == true
})
}
@Test("emphasis at start of string matches")
func emphasisAtStart() {
let r = OrgBodyRenderer.render("*first* word")
#expect(String(r.inline.characters) == "first word")
#expect(hasAttribute(r.inline, over: "first") { c in
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
})
}
@Test("emphasis at end of string matches")
func emphasisAtEnd() {
let r = OrgBodyRenderer.render("ending with /italic/")
#expect(String(r.inline.characters) == "ending with italic")
#expect(hasAttribute(r.inline, over: "italic") { c in
c.inlinePresentationIntent?.contains(.emphasized) == true
})
}
@Test("marker with whitespace inside does not match")
func whitespaceInsideDoesNotMatch() {
let r = OrgBodyRenderer.render("not *open close* but * x * stays")
#expect(String(r.inline.characters) == "not open close but * x * stays")
// The second * ... * has whitespace adjacent to markers so stays literal.
#expect(hasAttribute(r.inline, over: "* x *") { c in
c.inlinePresentationIntent?.contains(.stronglyEmphasized) != true
})
}
@Test("emphasis inside word boundary does not match (no mid-word triggers)")
func noMidWord() {
let r = OrgBodyRenderer.render("foo*bar*baz")
// Preceded by `o` (word char) -> not a valid opening context per our rules.
#expect(String(r.inline.characters) == "foo*bar*baz")
}
@Test("code protects the asterisk inside")
func codeWrapsAsterisk() {
let r = OrgBodyRenderer.render("literal =*not bold*= here")
#expect(String(r.inline.characters) == "literal *not bold* here")
#expect(hasAttribute(r.inline, over: "*not bold*") { c in
c.inlinePresentationIntent?.contains(.code) == true
})
// And the inner asterisks did not produce bold on "not bold".
#expect(hasAttribute(r.inline, over: "not bold") { c in
c.inlinePresentationIntent?.contains(.stronglyEmphasized) != true
})
}
}
@Suite("OrgBodyRenderer links and URLs")
struct OrgBodyLinksTests {
@Test("bracket link with label renders label as link")
func bracketLinkLabel() {
let r = OrgBodyRenderer.render("See [[https://example.com][the docs]] please")
#expect(String(r.inline.characters) == "See the docs please")
#expect(hasAttribute(r.inline, over: "the docs") {
$0.link == URL(string: "https://example.com")
})
}
@Test("bracket link without label uses the URL as text")
func bracketLinkBare() {
let r = OrgBodyRenderer.render("Docs: [[https://example.com/x]]")
#expect(String(r.inline.characters).contains("https://example.com/x"))
#expect(hasAttribute(r.inline, over: "https://example.com/x") {
$0.link == URL(string: "https://example.com/x")
})
}
@Test("bare URL in plain text becomes a link")
func bareURL() {
let r = OrgBodyRenderer.render("Visit https://example.com for more.")
#expect(hasAttribute(r.inline, over: "https://example.com") {
$0.link == URL(string: "https://example.com")
})
}
@Test("trailing punctuation stays outside the URL link")
func bareURLTrailingPunct() {
let r = OrgBodyRenderer.render("see https://example.com/foo.")
#expect(hasAttribute(r.inline, over: "https://example.com/foo") {
$0.link == URL(string: "https://example.com/foo")
})
// The trailing dot is NOT part of the link.
let full = String(r.inline.characters)
guard let dotRange = full.range(of: ".", options: .backwards) else { return }
let offset = full.distance(from: full.startIndex, to: dotRange.lowerBound)
let idx = r.inline.characters.index(r.inline.characters.startIndex, offsetBy: offset)
let afterIdx = r.inline.characters.index(after: idx)
let dotRun = r.inline[idx..<afterIdx]
for run in dotRun.runs {
#expect(run.attributes.link == nil)
}
}
@Test("image links are extracted, not inlined")
func imageExtracted() {
let r = OrgBodyRenderer.render("Look: [[https://cdn.test/cat.jpg][my cat]] end")
#expect(r.imageURLs.map(\.absoluteString) == ["https://cdn.test/cat.jpg"])
#expect(String(r.inline.characters).trimmingCharacters(in: .whitespaces).contains("my cat") == false)
}
@Test("bare image link is extracted")
func bareImage() {
let r = OrgBodyRenderer.render("[[https://cdn.test/a.png]]")
#expect(r.imageURLs.map(\.absoluteString) == ["https://cdn.test/a.png"])
}
}
@Suite("OrgBodyRenderer mentions")
struct OrgBodyMentionsTests {
@Test("mention is extracted into side-car list and rendered inline as @nick")
func mention() {
let body = "Hola [[org-social:https://ex.com/social.org][alice]]!"
let r = OrgBodyRenderer.render(body)
#expect(r.mentions == [OrgMention(nick: "alice",
feedURL: URL(string: "https://ex.com/social.org")!)])
// Inline text keeps the readable @nick form so the sentence stays intact.
#expect(String(r.inline.characters).contains("@alice"))
// The raw Org link syntax is gone from the inline text.
#expect(String(r.inline.characters).contains("org-social:") == false)
}
@Test("duplicate mentions are deduplicated")
func mentionDedup() {
let url = "https://ex.com/social.org"
let body = "[[org-social:\(url)][a]] and [[org-social:\(url)][a]]"
let r = OrgBodyRenderer.render(body)
#expect(r.mentions.count == 1)
}
}
@Suite("OrgBodyRenderer fenced blocks")
struct OrgBodyBlocksTests {
@Test("SRC block captured with language")
func srcWithLang() {
let body = """
before
#+BEGIN_SRC swift
let x = *not bold*
#+END_SRC
after
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
if case .src(let lang) = r.blocks[0].kind {
#expect(lang == "swift")
} else {
Issue.record("expected .src block, got \(r.blocks[0].kind)")
}
#expect(r.blocks[0].content == "let x = *not bold*")
#expect(String(r.inline.characters).contains("not bold") == false)
}
@Test("QUOTE block captured")
func quote() {
let body = """
#+BEGIN_QUOTE
something insightful
#+END_QUOTE
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
#expect(r.blocks[0].kind == .quote)
}
@Test("EXAMPLE block protects inner markers")
func example() {
let body = """
Preamble.
#+BEGIN_EXAMPLE
*foo* /bar/ ~baz~
#+END_EXAMPLE
And after.
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
#expect(r.blocks[0].kind == .example)
#expect(r.blocks[0].content == "*foo* /bar/ ~baz~")
// emphasis wasn't applied to the block contents
let text = String(r.inline.characters)
#expect(text.contains("*foo*") == false)
}
}
@Suite("OrgBodyRenderer mixed and empty")
struct OrgBodyMixedTests {
@Test("empty body returns empty output")
func empty() {
let r = OrgBodyRenderer.render("")
#expect(String(r.inline.characters) == "")
#expect(r.blocks.isEmpty)
#expect(r.mentions.isEmpty)
#expect(r.imageURLs.isEmpty)
}
@Test("mixed content: emphasis + link + bare URL + mention")
func mixed() {
let body = "*Hi* [[org-social:https://a.com/s.org][a]] see https://b.com and [[https://c.com][docs]]."
let r = OrgBodyRenderer.render(body)
let text = String(r.inline.characters)
#expect(text.contains("Hi"))
#expect(text.contains("https://b.com"))
#expect(text.contains("docs"))
#expect(text.contains("[[") == false)
#expect(r.mentions.count == 1)
#expect(hasAttribute(r.inline, over: "Hi") { c in
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
})
#expect(hasAttribute(r.inline, over: "https://b.com") {
$0.link == URL(string: "https://b.com")
})
#expect(hasAttribute(r.inline, over: "docs") {
$0.link == URL(string: "https://c.com")
})
}
@Test("punctuation immediately after emphasis still matches")
func punctAfter() {
let r = OrgBodyRenderer.render("This is *cool*!")
#expect(String(r.inline.characters) == "This is cool!")
#expect(hasAttribute(r.inline, over: "cool") { c in
c.inlinePresentationIntent?.contains(.stronglyEmphasized) == true
})
}
}