Files
andros 27a9bbffac Render code blocks in posts; fix parser for nested :PROPERTIES: in fenced blocks; bump 1.3 (19)
- Add HighlighterSwift dependency for syntax-highlighted #+BEGIN_SRC blocks
- New CodeBlockView: SRC (highlighted), QUOTE (accent border), EXAMPLE (monospace)
- Fix OrgSocialParser.parsePostBlock: break after outer :END: so nested :PROPERTIES:/:END: inside #+BEGIN_SRC blocks no longer overwrite contentStart and eat intro text
- Fix OrgSocialParser.extractText: preserve fenced block delimiters and content
- Fix OrgSocialParser post-grouping: ignore ** headings inside fenced blocks
- Fix PartialFeedFetcher.extractRecentPostBlocks: same fenced-block guard
- Add 9 OrgBodyRenderer edge-case tests (block at start/end, multiple blocks, org headings inside block, etc.)
- Add 2 parser tests for nested :PROPERTIES: inside fenced block body
2026-05-22 08:12:30 +02:00

492 lines
18 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 fenced block edge cases")
struct OrgBodyBlockEdgeCaseTests {
@Test("block at the very start, no intro text")
func blockAtStart() {
let body = """
#+BEGIN_SRC swift
let x = 1
#+END_SRC
Trailing text.
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
if case .src(let lang) = r.blocks[0].kind { #expect(lang == "swift") }
#expect(r.blocks[0].content == "let x = 1")
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(text == "Trailing text.")
#expect(text.contains("let x") == false)
#expect(text.contains("#+BEGIN_SRC") == false)
}
@Test("block at the very end, no trailing text")
func blockAtEnd() {
let body = """
Intro text.
#+BEGIN_SRC python
print("hello")
#+END_SRC
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
if case .src(let lang) = r.blocks[0].kind { #expect(lang == "python") }
#expect(r.blocks[0].content == "print(\"hello\")")
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(text == "Intro text.")
#expect(text.contains("print") == false)
}
@Test("block sandwiched between intro and trailing text")
func blockBetweenText() {
let body = """
Intro text here.
#+BEGIN_SRC bash
echo hello
#+END_SRC
Trailing text here.
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
#expect(r.blocks[0].content == "echo hello")
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(text.contains("Intro text here."))
#expect(text.contains("Trailing text here."))
#expect(text.contains("echo") == false)
#expect(text.contains("#+BEGIN_SRC") == false)
}
@Test("two blocks in sequence, no text between them")
func twoBlocksAdjacent() {
let body = """
Intro.
#+BEGIN_SRC swift
let a = 1
#+END_SRC
#+BEGIN_SRC python
b = 2
#+END_SRC
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 2)
#expect(r.blocks[0].content == "let a = 1")
#expect(r.blocks[1].content == "b = 2")
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(text.contains("Intro."))
#expect(text.contains("let a") == false)
#expect(text.contains("b = 2") == false)
}
@Test("text, block, text, block, text interleaved")
func textBlockInterleaved() {
let body = """
First paragraph.
#+BEGIN_SRC emacs-lisp
(message "hello")
#+END_SRC
Middle paragraph.
#+BEGIN_QUOTE
A wise quote.
#+END_QUOTE
Last paragraph.
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 2)
if case .src(let lang) = r.blocks[0].kind { #expect(lang == "emacs-lisp") }
#expect(r.blocks[0].content == "(message \"hello\")")
#expect(r.blocks[1].kind == .quote)
#expect(r.blocks[1].content == "A wise quote.")
let text = String(r.inline.characters)
#expect(text.contains("First paragraph."))
#expect(text.contains("Middle paragraph."))
#expect(text.contains("Last paragraph."))
#expect(text.contains("message") == false)
#expect(text.contains("wise quote") == false)
}
@Test("SRC block containing org headings is extracted correctly")
func srcBlockWithOrgHeadings() {
let body = """
Intro explaining the org snippet.
#+BEGIN_SRC org
** 2025-05-21T09:00:00+02:00
:PROPERTIES:
:CLIENT: org-social.el
:END:
Post content inside org snippet.
#+END_SRC
Trailing explanation.
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
if case .src(let lang) = r.blocks[0].kind { #expect(lang == "org") }
#expect(r.blocks[0].content.contains("** 2025-05-21T"))
#expect(r.blocks[0].content.contains(":CLIENT:"))
let text = String(r.inline.characters).trimmingCharacters(in: .whitespacesAndNewlines)
#expect(text.contains("Intro explaining the org snippet."))
#expect(text.contains("Trailing explanation."))
#expect(text.contains("** 2025") == false)
#expect(text.contains("#+BEGIN_SRC") == false)
}
@Test("multi-line block content is fully captured")
func multiLineBlockContent() {
let body = """
Before.
#+BEGIN_EXAMPLE
line one
line two
line three
#+END_EXAMPLE
After.
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
#expect(r.blocks[0].kind == .example)
#expect(r.blocks[0].content == "line one\nline two\nline three")
}
@Test("block without language tag is still extracted")
func srcWithoutLanguage() {
let body = """
Text before.
#+BEGIN_SRC
some code
#+END_SRC
Text after.
"""
let r = OrgBodyRenderer.render(body)
#expect(r.blocks.count == 1)
if case .src(let lang) = r.blocks[0].kind { #expect(lang == nil) }
#expect(r.blocks[0].content == "some code")
}
@Test("block delimiters are not present in inline text")
func noDelimitersInInline() {
let body = """
Before.
#+BEGIN_SRC js
console.log("hi")
#+END_SRC
After.
"""
let r = OrgBodyRenderer.render(body)
let text = String(r.inline.characters)
#expect(text.contains("#+BEGIN_SRC") == false)
#expect(text.contains("#+END_SRC") == false)
#expect(text.contains("console") == 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
})
}
}