diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Extraction/MarkdownExtractor.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Extraction/MarkdownExtractor.swift new file mode 100644 index 00000000..4eaed09a --- /dev/null +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Extraction/MarkdownExtractor.swift @@ -0,0 +1,335 @@ +import UIKit + +/// Reconstructs markdown source from a rendered `NSAttributedString`. +/// +/// The renderers strip the original markdown syntax, so a partial selection is +/// reverse-engineered from the custom `MarkdownAttribute` keys the renderers +/// leave behind. Block chrome (list markers, blockquote bars, code-block +/// backgrounds) lives in decoration views rather than the text itself, which is +/// why prefixes are derived from attributes instead of the string content. +/// +/// Reconstruction is CommonMark-equivalent, not byte-identical: soft breaks +/// come back as spaces and heading text loses inline markers. A selection that +/// covers the whole document therefore returns the original source verbatim. +enum MarkdownExtractor { + /// Markdown for `range`, using `sourceMarkdown` verbatim when the range + /// covers the entire rendered document. + static func markdown( + for range: NSRange, + in attributedText: NSAttributedString, + sourceMarkdown: String? + ) -> String? { + guard let clamped = clampedRange(range, in: attributedText) else { return nil } + + if let sourceMarkdown, isFullSelection(clamped, in: attributedText) { + return sourceMarkdown + } + return extractMarkdown(from: attributedText, in: clamped) + } + + /// Best-effort markdown reconstruction for a partial selection. + static func extractMarkdown(from attributedText: NSAttributedString, in range: NSRange) -> String? { + guard let clamped = clampedRange(range, in: attributedText) else { return nil } + + var result = "" + var state = ExtractionState() + + // Headings may span multiple attribute runs. + var currentHeadingLevel: Int? + var headingContent = "" + + func flushHeading() { + guard let level = currentHeadingLevel, !headingContent.isEmpty else { + currentHeadingLevel = nil + headingContent = "" + return + } + ensureBlankLine(&result) + result += String(repeating: "#", count: level) + " " + headingContent + "\n" + currentHeadingLevel = nil + headingContent = "" + state.needsBlankLine = true + } + + attributedText.enumerateAttributes(in: clamped, options: []) { attrs, attrRange, _ in + let text = (attributedText.string as NSString).substring(with: attrRange) + guard !text.isEmpty else { return } + + // Images and thematic breaks + if let attachment = attrs[.attachment] as? MarkdownImageAttachment { + guard !attachment.imageURL.isEmpty else { return } + if attachment.isInline { + result += "![image](\(attachment.imageURL))" + } else { + ensureBlankLine(&result) + result += "![image](\(attachment.imageURL))\n" + state.needsBlankLine = true + state.blockquoteDepth = -1 + state.listDepth = -1 + } + return + } + + if attrs[.attachment] is ThematicBreakAttachment { + ensureBlankLine(&result) + result += "---\n" + state.needsBlankLine = true + state.blockquoteDepth = -1 + state.listDepth = -1 + return + } + + if text == "\u{FFFC}" { + return + } + + // Newline runs (paragraph breaks and margin/padding spacers). + // Checked before the code-block branch so code-block padding + // spacers never open or close fences. + if text.allSatisfy({ $0 == "\n" }) { + let inBlockquote = MarkdownAttributeValue.intValue( + from: attrs[MarkdownAttribute.blockquoteDepth] + ) != nil + let inList = MarkdownAttributeValue.intValue( + from: attrs[MarkdownAttribute.listDepth] + ) != nil + + if !inBlockquote, state.blockquoteDepth >= 0 { + ensureBlankLine(&result) + state.blockquoteDepth = -1 + return + } + + if !inList, state.listDepth >= 0 { + ensureBlankLine(&result) + state.listDepth = -1 + return + } + + if inBlockquote || inList { + if !result.hasSuffix("\n") { + result += "\n" + } + return + } + + ensureBlankLine(&result) + return + } + + // Headings + if let level = MarkdownAttributeValue.intValue(from: attrs[MarkdownAttribute.headingLevel]) { + if level != currentHeadingLevel { + flushHeading() + currentHeadingLevel = level + } + headingContent += text.trimmingCharacters(in: .newlines) + return + } else if currentHeadingLevel != nil { + flushHeading() + } + + // Code blocks + if MarkdownAttributeValue.boolValue(from: attrs[MarkdownAttribute.codeBlock]) { + if state.needsBlankLine { + ensureBlankLine(&result) + state.needsBlankLine = false + } + + if result.isEmpty || result.hasSuffix("\n\n") { + result += "```\n" + } + + result += text + + if text.hasSuffix("\n") { + result += "```\n" + state.needsBlankLine = true + } + return + } + + // Blockquotes + let currentBlockquoteDepth = MarkdownAttributeValue.intValue( + from: attrs[MarkdownAttribute.blockquoteDepth] + ) ?? -1 + var blockquotePrefix: String? + + if currentBlockquoteDepth >= 0 { + blockquotePrefix = Self.blockquotePrefix(depth: currentBlockquoteDepth) + state.blockquoteDepth = currentBlockquoteDepth + } else if state.blockquoteDepth >= 0 { + ensureBlankLine(&result) + state.blockquoteDepth = -1 + } + + // Lists + let currentListDepth = MarkdownAttributeValue.intValue( + from: attrs[MarkdownAttribute.listDepth] + ) + + if let currentListDepth { + state.listDepth = currentListDepth + } else if state.listDepth >= 0 { + ensureBlankLine(&result) + state.listDepth = -1 + } + + // Inline formatting. Hard line breaks render as U+2028; map them + // back to newlines before wrapping. + let segmentText = text.replacingOccurrences(of: "\u{2028}", with: "\n") + var segment = applyInlineFormatting(segmentText, traits: InlineTraits(attrs: attrs)) + + // Block prefixes at line start + if isAtLineStart(result) { + var prefix = "" + + if let currentListDepth, !text.hasPrefix("\n") { + let isOrdered = MarkdownAttributeValue.intValue( + from: attrs[MarkdownAttribute.listType] + ) == ListType.ordered.rawValue + let itemNumber = MarkdownAttributeValue.intValue( + from: attrs[MarkdownAttribute.listItemNumber] + ) ?? 1 + prefix += listPrefix(depth: currentListDepth, isOrdered: isOrdered, itemNumber: itemNumber) + } + + if let blockquotePrefix { + prefix = blockquotePrefix + prefix + } + + segment = prefix + segment + } + + if state.needsBlankLine, !result.isEmpty { + ensureBlankLine(&result) + state.needsBlankLine = false + } + + result += segment + } + + flushHeading() + + return result.isEmpty ? nil : result + } + + /// http(s) URLs of image attachments within `range`, in document order. + static func imageURLs(in attributedText: NSAttributedString, range: NSRange) -> [String] { + guard let clamped = clampedRange(range, in: attributedText) else { return [] } + + var urls: [String] = [] + attributedText.enumerateAttribute(.attachment, in: clamped, options: []) { value, _, _ in + guard let attachment = value as? MarkdownImageAttachment else { return } + let url = attachment.imageURL + let lowercased = url.lowercased() + if lowercased.hasPrefix("http://") || lowercased.hasPrefix("https://") { + urls.append(url) + } + } + return urls + } +} + +private extension MarkdownExtractor { + struct ExtractionState { + var blockquoteDepth = -1 + var listDepth = -1 + var needsBlankLine = false + } + + struct InlineTraits { + let isInlineCode: Bool + let isStrong: Bool + let isEmphasis: Bool + let isStrikethrough: Bool + let isUnderline: Bool + let linkURL: String? + + init(attrs: [NSAttributedString.Key: Any]) { + isInlineCode = MarkdownAttributeValue.boolValue(from: attrs[MarkdownAttribute.inlineCode]) + isStrong = MarkdownAttributeValue.boolValue(from: attrs[MarkdownAttribute.strong]) + isEmphasis = MarkdownAttributeValue.boolValue(from: attrs[MarkdownAttribute.emphasis]) + isStrikethrough = (MarkdownAttributeValue.intValue(from: attrs[.strikethroughStyle]) ?? 0) != 0 + isUnderline = (MarkdownAttributeValue.intValue(from: attrs[.underlineStyle]) ?? 0) != 0 + + switch attrs[.link] { + case let url as URL: + linkURL = url.absoluteString + case let string as String: + linkURL = string + default: + linkURL = nil + } + } + } + + static func clampedRange(_ range: NSRange, in attributedText: NSAttributedString) -> NSRange? { + guard range.location != NSNotFound, + range.location >= 0, + range.length > 0, + range.location < attributedText.length + else { + return nil + } + return NSRange( + location: range.location, + length: min(range.length, attributedText.length - range.location) + ) + } + + /// A selection is "full" when it starts at the beginning and any excluded + /// tail is whitespace-only (themes append 1–2 trailing margin spacers that + /// Select All may skip). + static func isFullSelection(_ range: NSRange, in attributedText: NSAttributedString) -> Bool { + guard range.location == 0 else { return false } + let tail = (attributedText.string as NSString).substring(from: NSMaxRange(range)) + return tail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + static func ensureBlankLine(_ result: inout String) { + guard !result.isEmpty, !result.hasSuffix("\n\n") else { return } + result += result.hasSuffix("\n") ? "\n" : "\n\n" + } + + static func isAtLineStart(_ result: String) -> Bool { + result.isEmpty || result.hasSuffix("\n") + } + + /// Depth 0 = "> ", depth 1 = "> > ", etc. + static func blockquotePrefix(depth: Int) -> String { + String(repeating: "> ", count: depth + 1) + } + + static func listPrefix(depth: Int, isOrdered: Bool, itemNumber: Int) -> String { + let indent = String(repeating: " ", count: depth * 2) + let marker = isOrdered ? "\(itemNumber)." : "-" + return "\(indent)\(marker) " + } + + static func applyInlineFormatting(_ text: String, traits: InlineTraits) -> String { + var result = text + + // Innermost first + if traits.isInlineCode, traits.linkURL == nil { + result = "`\(result)`" + } + if traits.isStrikethrough { + result = "~~\(result)~~" + } + if traits.isUnderline, traits.linkURL == nil { + result = "\(result)" + } + if traits.isEmphasis { + result = "*\(result)*" + } + if traits.isStrong { + result = "**\(result)**" + } + if let linkURL = traits.linkURL { + result = "[\(result)](\(linkURL))" + } + + return result + } +} diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift index 7a61a66e..7bf45c80 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/RenderContext.swift @@ -24,6 +24,9 @@ struct BlockStyle { enum MarkdownAttribute { static let inlineCode = NSAttributedString.Key("EnrichedMarkdownInlineCode") static let codeBlock = NSAttributedString.Key("EnrichedMarkdownCodeBlock") + static let headingLevel = NSAttributedString.Key("EnrichedMarkdownHeadingLevel") + static let strong = NSAttributedString.Key("EnrichedMarkdownStrong") + static let emphasis = NSAttributedString.Key("EnrichedMarkdownEmphasis") static let blockquoteDepth = NSAttributedString.Key("EnrichedMarkdownBlockquoteDepth") static let blockquoteBackgroundColor = NSAttributedString.Key("EnrichedMarkdownBlockquoteBackgroundColor") static let listDepth = NSAttributedString.Key("EnrichedMarkdownListDepth") diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift index 4ceb990c..f3e0a012 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/EmphasisRenderer.swift @@ -16,6 +16,8 @@ final class EmphasisRenderer: NodeRenderer { let range = RenderContext.rangeForRenderedContent(in: output, start: start) guard range.length > 0 else { return } + output.addAttribute(MarkdownAttribute.emphasis, value: true, range: range) + let blockStyle = context.getBlockStyle() let blockColor = blockStyle?.color ?? UIColor.label let emphasisColor = config.emphasis.foregroundColor diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift index 1aecc2f7..0dae4c2f 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/HeadingRenderer.swift @@ -35,6 +35,12 @@ final class HeadingRenderer: NodeRenderer { guard output.length > start else { return } + output.addAttribute( + MarkdownAttribute.headingLevel, + value: level, + range: NSRange(location: contentStart, length: output.length - contentStart) + ) + let range = NSRange(location: start, length: output.length - start) if let lineHeight = headingStyle.lineHeight { diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift index 59c75817..3b885a14 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Rendering/Renderers/StrongRenderer.swift @@ -16,6 +16,8 @@ final class StrongRenderer: NodeRenderer { let range = RenderContext.rangeForRenderedContent(in: output, start: start) guard range.length > 0 else { return } + output.addAttribute(MarkdownAttribute.strong, value: true, range: range) + let blockStyle = context.getBlockStyle() let blockColor = blockStyle?.color ?? UIColor.label let strongColor = RenderContext.calculateStrongColor( diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift index 9354dab5..8117130d 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/EnrichedMarkdownText.swift @@ -27,6 +27,7 @@ public struct EnrichedMarkdownText: View { public var body: some View { MarkdownTextViewRepresentable( attributedText: renderStore.attributedText, + sourceMarkdown: renderStore.sourceMarkdown, styleConfig: styleConfig, onLinkPress: onLinkPress ) diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift index 7f9a2ae3..7eeb259c 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownRenderStore.swift @@ -4,6 +4,9 @@ import UIKit @MainActor final class MarkdownRenderStore: ObservableObject { @Published private(set) var attributedText = NSAttributedString() + // Published together with `attributedText` so consumers never pair a new + // markdown string with a stale render result. + @Published private(set) var sourceMarkdown: String? private let coordinator = AsyncRenderCoordinator() @@ -14,6 +17,7 @@ final class MarkdownRenderStore: ObservableObject { ) { if isBlank(markdown) { attributedText = NSAttributedString() + sourceMarkdown = nil return } @@ -21,6 +25,7 @@ final class MarkdownRenderStore: ObservableObject { MarkdownRenderer.render(markdown, config: config, flags: flags) } apply: { [weak self] result in self?.attributedText = result + self?.sourceMarkdown = markdown } } diff --git a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift index a9dba8b2..4d02681d 100644 --- a/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift +++ b/packages/ios-enriched-markdown/Sources/EnrichedMarkdown/Views/MarkdownTextViewRepresentable.swift @@ -3,6 +3,7 @@ import UIKit struct MarkdownTextViewRepresentable: UIViewRepresentable { let attributedText: NSAttributedString + let sourceMarkdown: String? let styleConfig: MarkdownStyleConfig let onLinkPress: ((URL) -> Void)? @@ -19,6 +20,7 @@ struct MarkdownTextViewRepresentable: UIViewRepresentable { func updateUIView(_ textView: MarkdownTextView, context: Context) { context.coordinator.onLinkPress = onLinkPress + context.coordinator.sourceMarkdown = sourceMarkdown textView.styleConfig = styleConfig textView.setMarkdownAttributedText(attributedText) } @@ -36,6 +38,7 @@ struct MarkdownTextViewRepresentable: UIViewRepresentable { final class Coordinator: NSObject, UITextViewDelegate { var onLinkPress: ((URL) -> Void)? + var sourceMarkdown: String? func textView( _ textView: UITextView, diff --git a/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/MarkdownExtractorTests.swift b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/MarkdownExtractorTests.swift new file mode 100644 index 00000000..fdf5f08b --- /dev/null +++ b/packages/ios-enriched-markdown/Tests/EnrichedMarkdownTests/MarkdownExtractorTests.swift @@ -0,0 +1,377 @@ +import UIKit +import XCTest +@testable import EnrichedMarkdown + +final class MarkdownExtractorTests: XCTestCase { + private var config: MarkdownStyleConfig! + + override func setUp() { + super.setUp() + config = MarkdownStyleConfig.baseline() + } + + // MARK: - Helpers + + private func render(_ markdown: String, flags: Md4cFlags = .commonMark) -> NSAttributedString { + MarkdownRenderer.render(markdown, config: config, flags: flags) + } + + private func extractSelecting( + _ substring: String, + in markdown: String, + flags: Md4cFlags = .commonMark, + file: StaticString = #filePath, + line: UInt = #line + ) -> String? { + let rendered = render(markdown, flags: flags) + let range = (rendered.string as NSString).range(of: substring) + XCTAssertNotEqual( + range.location, NSNotFound, + "substring '\(substring)' not found in rendered output '\(rendered.string)'", + file: file, line: line + ) + guard range.location != NSNotFound else { return nil } + return MarkdownExtractor.extractMarkdown(from: rendered, in: range) + } + + private func extractFullRange(_ markdown: String, flags: Md4cFlags = .commonMark) -> String? { + let rendered = render(markdown, flags: flags) + return MarkdownExtractor.extractMarkdown( + from: rendered, + in: NSRange(location: 0, length: rendered.length) + ) + } + + // MARK: - Invalid input + + func testReturnsNilForEmptyRange() { + let rendered = render("Hello") + XCTAssertNil(MarkdownExtractor.extractMarkdown(from: rendered, in: NSRange(location: 2, length: 0))) + } + + func testReturnsNilForOutOfBoundsRange() { + let rendered = render("Hello") + XCTAssertNil( + MarkdownExtractor.extractMarkdown( + from: rendered, + in: NSRange(location: rendered.length + 5, length: 3) + ) + ) + } + + // MARK: - Full selection + + func testFullSelectionReturnsSourceMarkdownVerbatim() { + let source = "# Title\n\nParagraph with **bold**." + let rendered = render(source) + + let markdown = MarkdownExtractor.markdown( + for: NSRange(location: 0, length: rendered.length), + in: rendered, + sourceMarkdown: source + ) + + XCTAssertEqual(markdown, source) + } + + func testFullSelectionToleratesExcludedTrailingSpacers() { + let source = "# Title\n\nParagraph with **bold**." + let rendered = render(source) + + let markdown = MarkdownExtractor.markdown( + for: NSRange(location: 0, length: rendered.length - 1), + in: rendered, + sourceMarkdown: source + ) + + XCTAssertEqual(markdown, source) + } + + func testPartialSelectionDoesNotReturnSourceMarkdown() { + let source = "First paragraph.\n\nSecond paragraph." + let rendered = render(source) + let range = (rendered.string as NSString).range(of: "Second paragraph.") + + let markdown = MarkdownExtractor.markdown(for: range, in: rendered, sourceMarkdown: source) + + XCTAssertEqual(markdown, "Second paragraph.") + } + + // MARK: - Inline elements + + func testExtractsPlainParagraphText() { + XCTAssertEqual(extractSelecting("Hello CommonMark", in: "Hello CommonMark"), "Hello CommonMark") + } + + func testExtractsPartialSelectionWithinParagraph() { + XCTAssertEqual(extractSelecting("Hello", in: "Hello world"), "Hello") + } + + func testExtractsBoldText() { + XCTAssertEqual(extractSelecting("31%", in: "Forests cover **31%** of land."), "**31%**") + } + + func testExtractsItalicText() { + XCTAssertEqual( + extractSelecting("300 million years", in: "Over *300 million years* old."), + "*300 million years*" + ) + } + + func testExtractsBoldAndItalicInSameParagraph() { + XCTAssertEqual( + extractSelecting( + "Text with bold and italic styles.", + in: "Text with **bold** and *italic* styles." + ), + "Text with **bold** and *italic* styles." + ) + } + + func testExtractsInlineCode() { + XCTAssertEqual(extractSelecting("48 pounds", in: "Use `48 pounds` per year."), "`48 pounds`") + } + + func testExtractsLink() { + XCTAssertEqual( + extractSelecting("Example link", in: "[Example link](https://example.com)"), + "[Example link](https://example.com)" + ) + } + + func testExtractsStrikethrough() { + XCTAssertEqual(extractSelecting("old value", in: "Price: ~~old value~~ new."), "~~old value~~") + } + + func testExtractsUnderline() { + XCTAssertEqual( + extractSelecting( + "underlined", + in: "Some _underlined_ text.", + flags: Md4cFlags(underline: true) + ), + "underlined" + ) + } + + func testLinkIsNotWrappedInUnderline() { + XCTAssertEqual( + extractSelecting("swmansion", in: "Visit [swmansion](https://swmansion.com) now."), + "[swmansion](https://swmansion.com)" + ) + } + + // MARK: - Headings + + func testExtractsHeading() { + XCTAssertEqual( + extractSelecting( + "The Hidden World of Forest Ecosystems", + in: "# The Hidden World of Forest Ecosystems" + ), + "# The Hidden World of Forest Ecosystems\n" + ) + } + + func testExtractsAllHeadingLevels() { + for level in 1...6 { + let title = "Heading level \(level)" + let expected = String(repeating: "#", count: level) + " " + title + "\n" + let source = String(repeating: "#", count: level) + " " + title + XCTAssertEqual(extractSelecting(title, in: source), expected) + } + } + + func testHeadingAttributeAppliedByRenderer() { + let rendered = render("# Hi") + let textRange = (rendered.string as NSString).range(of: "Hi") + + let level = MarkdownAttributeValue.intValue( + from: rendered.attribute(MarkdownAttribute.headingLevel, at: textRange.location, effectiveRange: nil) + ) + + XCTAssertEqual(level, 1) + } + + // MARK: - Blockquotes + + func testExtractsBlockquote() { + let quote = "In every walk with nature, one receives far more than he seeks." + XCTAssertEqual(extractSelecting(quote, in: "> \(quote)"), "> \(quote)") + } + + func testExtractsNestedBlockquote() { + XCTAssertEqual( + extractSelecting("Inner quote", in: "> Outer quote\n>\n> > Inner quote"), + "> > Inner quote" + ) + } + + func testExtractsBlockquoteWithFormattedText() { + XCTAssertEqual( + extractSelecting("Quote with bold text.", in: "> Quote with **bold** text."), + "> Quote with **bold** text." + ) + } + + // MARK: - Lists + + func testExtractsUnorderedListItem() { + XCTAssertEqual( + extractSelecting("Climate regulation", in: "- Climate regulation\n- Biodiversity"), + "- Climate regulation" + ) + } + + func testExtractsOrderedListItem() { + XCTAssertEqual( + extractSelecting("First item", in: "1. First item\n2. Second item"), + "1. First item" + ) + } + + func testExtractsSecondOrderedListItem() { + XCTAssertEqual( + extractSelecting("Second item", in: "1. First item\n2. Second item"), + "2. Second item" + ) + } + + func testExtractsNestedUnorderedListItem() { + XCTAssertEqual( + extractSelecting("Nested item", in: "- Parent item\n - Nested item"), + " - Nested item" + ) + } + + func testExtractsMultipleListItems() { + let rendered = render("- Alpha\n- Beta") + let string = rendered.string as NSString + let start = string.range(of: "Alpha") + let end = string.range(of: "Beta") + let range = NSRange(location: start.location, length: NSMaxRange(end) - start.location) + + XCTAssertEqual( + MarkdownExtractor.extractMarkdown(from: rendered, in: range), + "- Alpha\n- Beta" + ) + } + + // MARK: - Code blocks + + func testExtractsMultiLineCodeBlock() { + let code = "func main() {\n print(\"forest\")\n}\n" + let rendered = render("```\nfunc main() {\n print(\"forest\")\n}\n```") + let range = (rendered.string as NSString).range(of: code) + XCTAssertNotEqual(range.location, NSNotFound) + + XCTAssertEqual( + MarkdownExtractor.extractMarkdown(from: rendered, in: range), + "```\n\(code)```\n" + ) + } + + func testExtractsCodeBlockWithoutClosingFenceWhenSelectionExcludesTrailingNewline() { + let code = "let answer = 42" + XCTAssertEqual( + extractSelecting(code, in: "```\nlet answer = 42\n```"), + "```\n\(code)" + ) + } + + // MARK: - Thematic breaks and images + + func testExtractsThematicBreak() { + let rendered = render("Before\n\n---\n\nAfter") + let breakLocation = (rendered.string as NSString).range(of: "\u{FFFC}") + XCTAssertNotEqual(breakLocation.location, NSNotFound) + + XCTAssertEqual( + MarkdownExtractor.extractMarkdown(from: rendered, in: breakLocation), + "---\n" + ) + } + + func testExtractsInlineImage() { + let url = "https://example.com/forest.jpg" + let rendered = render("Before ![image](\(url)) after") + let imageLocation = (rendered.string as NSString).range(of: "\u{FFFC}") + XCTAssertNotEqual(imageLocation.location, NSNotFound) + + XCTAssertEqual( + MarkdownExtractor.extractMarkdown(from: rendered, in: imageLocation), + "![image](\(url))" + ) + } + + func testExtractsBlockImage() { + let url = "https://example.com/forest.jpg" + let rendered = render("![image](\(url))") + let imageLocation = (rendered.string as NSString).range(of: "\u{FFFC}") + XCTAssertNotEqual(imageLocation.location, NSNotFound) + + XCTAssertEqual( + MarkdownExtractor.extractMarkdown(from: rendered, in: imageLocation), + "![image](\(url))\n" + ) + } + + func testExtractsParagraphWithInlineImageAndText() { + let url = "https://example.com/forest.jpg" + let rendered = render("See ![image](\(url)) for details.") + let string = rendered.string as NSString + let start = string.range(of: "See ") + let end = string.range(of: " for details.") + let range = NSRange(location: start.location, length: NSMaxRange(end) - start.location) + + XCTAssertEqual( + MarkdownExtractor.extractMarkdown(from: rendered, in: range), + "See ![image](\(url)) for details." + ) + } + + // MARK: - Line breaks + + func testSoftBreakExtractsAsSpace() { + XCTAssertEqual(extractSelecting("alpha beta", in: "alpha\nbeta"), "alpha beta") + } + + func testHardBreakExtractsAsNewline() { + let extracted = extractFullRange("line one \nline two") + XCTAssertNotNil(extracted) + XCTAssertTrue( + extracted?.contains("line one\nline two") ?? false, + "expected hard break as newline in '\(extracted ?? "nil")'" + ) + } + + // MARK: - Image URLs + + func testImageURLsReturnsHttpUrlsInRange() { + let url = "https://example.com/forest.jpg" + let rendered = render("![image](\(url))") + + XCTAssertEqual( + MarkdownExtractor.imageURLs(in: rendered, range: NSRange(location: 0, length: rendered.length)), + [url] + ) + } + + func testImageURLsFiltersNonHttpSchemes() { + let rendered = render("![image](file:///tmp/local.png)") + + XCTAssertEqual( + MarkdownExtractor.imageURLs(in: rendered, range: NSRange(location: 0, length: rendered.length)), + [] + ) + } + + func testImageURLsReturnsEmptyForEmptyRange() { + let rendered = render("![image](https://example.com/forest.jpg)") + + XCTAssertEqual( + MarkdownExtractor.imageURLs(in: rendered, range: NSRange(location: 0, length: 0)), + [] + ) + } +}