From cf3d990b93076dbbb77946b8b07d247cef664b00 Mon Sep 17 00:00:00 2001 From: Chip Snyder Date: Fri, 17 Apr 2020 11:02:44 -0400 Subject: [PATCH 1/3] Add support to coverblock to handle nested blocks --- .../GutenbergBlockTreeProcessor.swift | 193 ++++++++++++++++++ .../GutenbergCoverUploadProcessor.swift | 19 +- WordPress/WordPress.xcodeproj/project.pbxproj | 4 + .../GutenbergCoverUploadProcessorTests.swift | 103 ++++++++-- 4 files changed, 300 insertions(+), 19 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockTreeProcessor.swift diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockTreeProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockTreeProcessor.swift new file mode 100644 index 000000000000..c53a9bb28bd7 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockTreeProcessor.swift @@ -0,0 +1,193 @@ +import Foundation +import Aztec + +/// A class that processes a Gutenberg post content and replaces the designated Gutenberg block for the replacement provided strings. +/// This works similarly to GutenbergBlockProcessor however it provides support for nested blocks of the same type. +/// +public class GutenbergBlockTreeProcessor: Processor { + + /// Whenever a Guntenberg block is found by the processor, this closure will be executed so that elements can be customized. + /// + public typealias Replacer = (GutenbergBlock) -> String? + + let name: String + + private enum CaptureGroups: Int { + case all = 0 + case name + case attributes + + static let allValues: [CaptureGroups] = [.all, .name, .attributes] + } + + // MARK: - Parsing & processing properties + private let replacer: Replacer + + // MARK: - Initializers + + public init(for blockName: String, replacer: @escaping Replacer) { + self.name = blockName + self.replacer = replacer + } + + /// Regular expression to detect attributes of the opening tag of a block + /// Capture groups: + /// + /// 1. The block id + /// 2. The block attributes + /// + var openTagRegex: NSRegularExpression { + let pattern = "\\" + return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + } + + /// Regular expression to detect the closing tag of a block + /// + var closingTagRegex: NSRegularExpression { + let pattern = "\\" + return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + } + + // MARK: - Processing + + /// Processes the block and for any needed replacements from a given opening tag match. + /// Delegates handeling of innerBlocks to the class's `replacer`. + /// - Parameters: + /// - text: The string that the following parameter is found in. + /// - Returns: The resulting string after the necessary replacements have occured + /// + public func process(_ text: String) -> String { + let matches = openTagRegex.matches(in: text, options: [], range: text.utf16NSRange(from: text.startIndex ..< text.endIndex)) + var replacements = [(NSRange, String)]() + + var lastReplacementBound = 0 + for match in matches { + if match.range.lowerBound >= lastReplacementBound, let replacement = process(match, in: text) { + replacements.append(replacement) + lastReplacementBound = replacement.0.upperBound + } + } + let resultText = replace(replacements, in: text) + return resultText + } + + /// Replaces the + /// - Parameters: + /// - replacements: An array of tuples representing first a range of text that needs to be replaced then the string to replace + /// - text: The string to perform the replacements on + /// + func replace(_ replacements: [(NSRange, String)], in text: String) -> String { + let mutableString = NSMutableString(string: text) + var offset = 0 + for (range, replacement) in replacements { + let lengthBefore = mutableString.length + let offsetRange = NSRange(location: range.location + offset, length: range.length) + mutableString.replaceCharacters(in: offsetRange, with: replacement) + let lengthAfter = mutableString.length + offset += (lengthAfter - lengthBefore) + } + return mutableString as String + } +} +// MARK: - Regex Match Processing Logic + +private extension GutenbergBlockTreeProcessor { + /// Processes the block and for any needed replacements from a given opening tag match. + /// Delegates handeling of innerBlocks to the replacer. + /// - Parameters: + /// - match: The match reperesenting an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: Any necessary replacements within the provided string + /// + private func process(_ match: NSTextCheckingResult, in text: String) -> (NSRange, String)? { + + var result: (NSRange, String)? = nil + if let closingRange = locateClosingTag(forMatch: match, in: text) { + let attributes = readAttributes(from: match, in: text) + let content = readContent(from: match, withClosingRange: closingRange, in: text) + let block = GutenbergBlock(name: name, attributes: attributes, content: content) + + if let replacement = replacer(block) { + let length = closingRange.upperBound - match.range.lowerBound + let range = NSRange(location: match.range.lowerBound, length: length) + result = (range, replacement) + } + } + + return result + } + + /// Determines the location of the closing block tag for the matching open tag + /// - Parameters: + /// - openTag: The match reperesenting an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: The Range of the closing tag for the block + /// + func locateClosingTag(forMatch openTag: NSTextCheckingResult, in text: String) -> NSRange? { + guard let index = text.indexFromLocation(openTag.range.upperBound) else { + return nil + } + + let matches = closingTagRegex.matches(in: text, options: [], range: text.utf16NSRange(from: index ..< text.endIndex)) + + for match in matches { + let content = readContent(from: openTag, withClosingRange: match.range, in: text) + + if tagsAreBalanced(in: content) { + return match.range + } + } + + return nil + } + + /// Determines if there are an equal number of opening and closing block tags in the provided text. + /// - Parameters: + /// - text: The string to test assumes that a block with an even number represents a valid block sequence. + /// - Returns: A boolean where true represents an equal number of opening and closing block tags of the desired type + /// + func tagsAreBalanced(in text: String) -> Bool { + + let range = text.utf16NSRange(from: text.startIndex ..< text.endIndex) + let openTags = openTagRegex.matches(in: text, options: [], range: range) + let closingTags = closingTagRegex.matches(in: text, options: [], range: range) + + return openTags.count == closingTags.count + } + + /// Obtains the block attributes from a regex match. + /// - Parameters: + /// - match: The `NSTextCheckingResult` from a successful regex detection of an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: A JSON dictionary of the block attributes + /// + func readAttributes(from match: NSTextCheckingResult, in text: String) -> [String: Any] { + guard let attributesText = match.captureGroup(in: CaptureGroups.attributes.rawValue, text: text), + let data = attributesText.data(using: .utf8 ), + let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments), + let jsonDictionary = json as? [String: Any] else { + return [:] + } + + return jsonDictionary + } + + /// Obtains the block content from a regex match and range. + /// - Parameters: + /// - match: The `NSTextCheckingResult` from a successful regex detection of an opening block tag + /// - closingRange: The `NSRange` of the closing block tag + /// - text: The string that the following parameters are found in. + /// - Returns: The content between the opening and closing tags of a block + /// + func readContent(from match: NSTextCheckingResult, withClosingRange closingRange: NSRange, in text: String) -> String { + guard let index = text.indexFromLocation(match.range.upperBound) else { + return "" + } + + guard let closingBound = text.indexFromLocation(closingRange.lowerBound) else { + return "" + } + + return String(text[index.. String { return coverBlockProcessor.process(text) } + + private func processInnerBlocks(_ outerBlock: GutenbergBlock) -> String { + var block = "" + block += coverBlockProcessor.process(outerBlock.content) + block += "" + return block + } } diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 46bc2a5de0be..1cfb82cf7f12 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -569,6 +569,7 @@ 4629E4212440C5B20002E15C /* GutenbergCoverUploadProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4629E4202440C5B20002E15C /* GutenbergCoverUploadProcessor.swift */; }; 4629E4232440C8160002E15C /* GutenbergCoverUploadProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4629E4222440C8160002E15C /* GutenbergCoverUploadProcessorTests.swift */; }; 462F4E0A18369F0B0028D2F8 /* BlogDetailsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 462F4E0718369F0B0028D2F8 /* BlogDetailsViewController.m */; }; + 46638DF6244904A3006E8439 /* GutenbergBlockTreeProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46638DF5244904A3006E8439 /* GutenbergBlockTreeProcessor.swift */; }; 4B2DD0F29CD6AC353C056D41 /* Pods_WordPressUITests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8DCE7542239FBC709B90EA85 /* Pods_WordPressUITests.framework */; }; 4C8A715EBCE7E73AEE216293 /* Pods_WordPressShareExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F47DB4A8EC2E6844E213A3FA /* Pods_WordPressShareExtension.framework */; }; 4D520D4F22972BC9002F5924 /* acknowledgements.html in Resources */ = {isa = PBXBuildFile; fileRef = 4D520D4E22972BC9002F5924 /* acknowledgements.html */; }; @@ -2882,6 +2883,7 @@ 4629E4222440C8160002E15C /* GutenbergCoverUploadProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergCoverUploadProcessorTests.swift; sourceTree = ""; }; 462F4E0618369F0B0028D2F8 /* BlogDetailsViewController.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = BlogDetailsViewController.h; sourceTree = ""; }; 462F4E0718369F0B0028D2F8 /* BlogDetailsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; lineEnding = 0; path = BlogDetailsViewController.m; sourceTree = ""; xcLanguageSpecificationIdentifier = xcode.lang.objc; }; + 46638DF5244904A3006E8439 /* GutenbergBlockTreeProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GutenbergBlockTreeProcessor.swift; sourceTree = ""; }; 46F84612185A8B7E009D0DA5 /* PostContentProvider.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = PostContentProvider.h; sourceTree = ""; }; 48690E659987FD4472EEDE5F /* Pods-WordPressNotificationContentExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WordPressNotificationContentExtension.release.xcconfig"; path = "../Pods/Target Support Files/Pods-WordPressNotificationContentExtension/Pods-WordPressNotificationContentExtension.release.xcconfig"; sourceTree = ""; }; 4D520D4E22972BC9002F5924 /* acknowledgements.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = acknowledgements.html; path = "../Pods/Target Support Files/Pods-WordPress/acknowledgements.html"; sourceTree = ""; }; @@ -10157,6 +10159,7 @@ 91138454228373EB00FB02B7 /* GutenbergVideoUploadProcessor.swift */, FF9C81C32375BA8100DC4B2F /* GutenbergBlockProcessor.swift */, 4629E4202440C5B20002E15C /* GutenbergCoverUploadProcessor.swift */, + 46638DF5244904A3006E8439 /* GutenbergBlockTreeProcessor.swift */, ); path = Processors; sourceTree = ""; @@ -12046,6 +12049,7 @@ E66969E21B9E67A000EC9C00 /* ReaderTopicToReaderSiteTopic37to38.swift in Sources */, B543D2B520570B5A00D3D4CC /* WordPressComSyncService.swift in Sources */, E14A52371E39F43E00EE203E /* AppRatingsUtility.swift in Sources */, + 46638DF6244904A3006E8439 /* GutenbergBlockTreeProcessor.swift in Sources */, 8350E49611D2C71E00A7B073 /* Media.m in Sources */, D8B9B58F204F4EA1003C6042 /* NetworkAware.swift in Sources */, B54346961C6A707D0010B3AD /* LanguageViewController.swift in Sources */, diff --git a/WordPress/WordPressTest/GutenbergCoverUploadProcessorTests.swift b/WordPress/WordPressTest/GutenbergCoverUploadProcessorTests.swift index b75276e458ba..ca27f25e390c 100644 --- a/WordPress/WordPressTest/GutenbergCoverUploadProcessorTests.swift +++ b/WordPress/WordPressTest/GutenbergCoverUploadProcessorTests.swift @@ -3,30 +3,40 @@ import XCTest class GutenbergCoverUploadProcessorTests: XCTestCase { - let postContent = """ - -
+ let paragraphBlock = """

-
- """ - let postResultContent = """ - -
- -

- -
- - """ + let gutenbergMediaUploadID = Int32(-1175513456) + let mediaID = 987 + let remoteURLStr = "http://www.wordpress.com/logo.jpg" + + func localCoverBlock(innerBlock: String, mediaID: Int32) -> String { + return """ + +
+ \(innerBlock) +
+ + """ + } + + func uploadedCoverBlock(innerBlock: String, mediaID: Int) -> String { + return """ + +
+ \(innerBlock) +
+ + """ + } func testCoverBlockProcessor() { - let gutenbergMediaUploadID = Int32(-1175513456) - let mediaID = 456 - let remoteURLStr = "http://www.wordpress.com/logo.jpg" + + let postContent = localCoverBlock(innerBlock: paragraphBlock, mediaID: gutenbergMediaUploadID) + let postResultContent = uploadedCoverBlock(innerBlock: paragraphBlock, mediaID: mediaID) let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) let resultContent = gutenbergCoverPostUploadProcessor.process(postContent) @@ -34,4 +44,63 @@ class GutenbergCoverUploadProcessorTests: XCTestCase { XCTAssertEqual(resultContent, postResultContent, "Post content should be updated correctly") } + func testMultipleCoverBlocksProcessor() { + + let coverBlock1 = uploadedCoverBlock(innerBlock: paragraphBlock, mediaID: 123) + let localBlock = localCoverBlock(innerBlock: paragraphBlock, mediaID: gutenbergMediaUploadID) + let coverBlock2 = uploadedCoverBlock(innerBlock: paragraphBlock, mediaID: 456) + + let postContent = "\(coverBlock1) \(localBlock) \(coverBlock2)" + + let uploadedBlock = uploadedCoverBlock(innerBlock: paragraphBlock, mediaID: mediaID) + let postResultContent = "\(coverBlock1) \(uploadedBlock) \(coverBlock2)" + + let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let resultContent = gutenbergCoverPostUploadProcessor.process(postContent) + + XCTAssertEqual(resultContent, postResultContent, "Post content should be updated correctly") + } + + func testNestedCoverBlockProcessor() { + + let nestedBlock = localCoverBlock(innerBlock: paragraphBlock, mediaID: gutenbergMediaUploadID) + let postContent = uploadedCoverBlock(innerBlock: nestedBlock, mediaID: 123) + + let uploadedNestedBlock = uploadedCoverBlock(innerBlock: paragraphBlock, mediaID: mediaID) + let postResultContent = uploadedCoverBlock(innerBlock: uploadedNestedBlock, mediaID: 123) + + let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let resultContent = gutenbergCoverPostUploadProcessor.process(postContent) + + XCTAssertEqual(resultContent, postResultContent, "Post content should be updated correctly") + } + + func testDeepNestedCoverBlockProcessor() { + + let nestedBlock = localCoverBlock(innerBlock: paragraphBlock, mediaID: gutenbergMediaUploadID) + let innerBlock = uploadedCoverBlock(innerBlock: nestedBlock, mediaID: 457) + let postContent = uploadedCoverBlock(innerBlock: innerBlock, mediaID: 123) + + let uploadedNestedBlock = uploadedCoverBlock(innerBlock: paragraphBlock, mediaID: mediaID) + let innerBlockWithUploadedBlock = uploadedCoverBlock(innerBlock: uploadedNestedBlock, mediaID: 457) + let postResultContent = uploadedCoverBlock(innerBlock: innerBlockWithUploadedBlock, mediaID: 123) + + let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let resultContent = gutenbergCoverPostUploadProcessor.process(postContent) + + XCTAssertEqual(resultContent, postResultContent, "Post content should be updated correctly") + } + + func testUpdateOuterCoverBlockProcessor() { + + let innerBlock = uploadedCoverBlock(innerBlock: paragraphBlock, mediaID: 457) + let postContent = localCoverBlock(innerBlock: innerBlock, mediaID: gutenbergMediaUploadID) + + let postResultContent = uploadedCoverBlock(innerBlock: innerBlock, mediaID: mediaID) + + let gutenbergCoverPostUploadProcessor = GutenbergCoverUploadProcessor(mediaUploadID: gutenbergMediaUploadID, serverMediaID: mediaID, remoteURLString: remoteURLStr) + let resultContent = gutenbergCoverPostUploadProcessor.process(postContent) + + XCTAssertEqual(resultContent, postResultContent, "Post content should be updated correctly") + } } From 6775b743a330d7cd4585526a3a689af3a8d29762 Mon Sep 17 00:00:00 2001 From: Chip Snyder Date: Fri, 17 Apr 2020 11:21:33 -0400 Subject: [PATCH 2/3] Move the parsing of content to the block tree parser --- .../Gutenberg/Processors/GutenbergBlockTreeProcessor.swift | 5 ++--- .../Gutenberg/Processors/GutenbergCoverUploadProcessor.swift | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockTreeProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockTreeProcessor.swift index c53a9bb28bd7..33c9fc763bde 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockTreeProcessor.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergBlockTreeProcessor.swift @@ -51,7 +51,6 @@ public class GutenbergBlockTreeProcessor: Processor { // MARK: - Processing /// Processes the block and for any needed replacements from a given opening tag match. - /// Delegates handeling of innerBlocks to the class's `replacer`. /// - Parameters: /// - text: The string that the following parameter is found in. /// - Returns: The resulting string after the necessary replacements have occured @@ -93,7 +92,6 @@ public class GutenbergBlockTreeProcessor: Processor { private extension GutenbergBlockTreeProcessor { /// Processes the block and for any needed replacements from a given opening tag match. - /// Delegates handeling of innerBlocks to the replacer. /// - Parameters: /// - match: The match reperesenting an opening block tag /// - text: The string that the following parameter is found in. @@ -105,7 +103,8 @@ private extension GutenbergBlockTreeProcessor { if let closingRange = locateClosingTag(forMatch: match, in: text) { let attributes = readAttributes(from: match, in: text) let content = readContent(from: match, withClosingRange: closingRange, in: text) - let block = GutenbergBlock(name: name, attributes: attributes, content: content) + let parsedContent = process(content) // Recurrsively parse nested blocks and process those seperatly + let block = GutenbergBlock(name: name, attributes: attributes, content: parsedContent) if let replacement = replacer(block) { let length = closingRange.upperBound - match.range.lowerBound diff --git a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergCoverUploadProcessor.swift b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergCoverUploadProcessor.swift index 18278e31baa2..cf992923368e 100644 --- a/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergCoverUploadProcessor.swift +++ b/WordPress/Classes/ViewRelated/Gutenberg/Processors/GutenbergCoverUploadProcessor.swift @@ -17,7 +17,7 @@ class GutenbergCoverUploadProcessor: Processor { lazy var coverBlockProcessor = GutenbergBlockTreeProcessor(for: "wp:cover", replacer: { coverBlock in guard let mediaID = coverBlock.attributes["id"] as? Int, mediaID == self.mediaUploadID else { - return self.processInnerBlocks(coverBlock) + return nil } var block = "([\\s\\S]*?)" - let regex = try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) - - return RegexProcessor(regex: regex) { (match: NSTextCheckingResult, text: String) -> String? in - return self?.process(match: match, text: text) - } - }() - // MARK: - Parsing & processing properties private let replacer: Replacer @@ -58,36 +37,137 @@ public class GutenbergBlockProcessor: Processor { self.replacer = replacer } + /// Regular expression to detect attributes of the opening tag of a block + /// Capture groups: + /// + /// 1. The block id + /// 2. The block attributes + /// + var openTagRegex: NSRegularExpression { + let pattern = "\\" + return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + } + + /// Regular expression to detect the closing tag of a block + /// + var closingTagRegex: NSRegularExpression { + let pattern = "\\" + return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) + } + // MARK: - Processing + /// Processes the block and for any needed replacements from a given opening tag match. + /// - Parameters: + /// - text: The string that the following parameter is found in. + /// - Returns: The resulting string after the necessary replacements have occured + /// public func process(_ text: String) -> String { - return gutenbergBlockRegexProcessor.process(text) + let matches = openTagRegex.matches(in: text, options: [], range: text.utf16NSRange(from: text.startIndex ..< text.endIndex)) + var replacements = [(NSRange, String)]() + + var lastReplacementBound = 0 + for match in matches { + if match.range.lowerBound >= lastReplacementBound, let replacement = process(match, in: text) { + replacements.append(replacement) + lastReplacementBound = replacement.0.upperBound + } + } + let resultText = replace(replacements, in: text) + return resultText } -} + /// Replaces the + /// - Parameters: + /// - replacements: An array of tuples representing first a range of text that needs to be replaced then the string to replace + /// - text: The string to perform the replacements on + /// + func replace(_ replacements: [(NSRange, String)], in text: String) -> String { + let mutableString = NSMutableString(string: text) + var offset = 0 + for (range, replacement) in replacements { + let lengthBefore = mutableString.length + let offsetRange = NSRange(location: range.location + offset, length: range.length) + mutableString.replaceCharacters(in: offsetRange, with: replacement) + let lengthAfter = mutableString.length + offset += (lengthAfter - lengthBefore) + } + return mutableString as String + } +} // MARK: - Regex Match Processing Logic private extension GutenbergBlockProcessor { - /// Processes an Gutenberg block regex match. + /// Processes the block and for any needed replacements from a given opening tag match. + /// - Parameters: + /// - match: The match reperesenting an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: Any necessary replacements within the provided string /// - func process(match: NSTextCheckingResult, text: String) -> String? { + private func process(_ match: NSTextCheckingResult, in text: String) -> (NSRange, String)? { + + var result: (NSRange, String)? = nil + if let closingRange = locateClosingTag(forMatch: match, in: text) { + let attributes = readAttributes(from: match, in: text) + let content = readContent(from: match, withClosingRange: closingRange, in: text) + let parsedContent = process(content) // Recurrsively parse nested blocks and process those seperatly + let block = GutenbergBlock(name: name, attributes: attributes, content: parsedContent) + + if let replacement = replacer(block) { + let length = closingRange.upperBound - match.range.lowerBound + let range = NSRange(location: match.range.lowerBound, length: length) + result = (range, replacement) + } + } - guard match.numberOfRanges == CaptureGroups.allValues.count else { + return result + } + + /// Determines the location of the closing block tag for the matching open tag + /// - Parameters: + /// - openTag: The match reperesenting an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: The Range of the closing tag for the block + /// + func locateClosingTag(forMatch openTag: NSTextCheckingResult, in text: String) -> NSRange? { + guard let index = text.indexFromLocation(openTag.range.upperBound) else { return nil } - let attributes = readAttributes(from: match, in: text) - let content = readContent(from: match, in: text) - let block = GutenbergBlock(name: name, attributes: attributes, content: content) + let matches = closingTagRegex.matches(in: text, options: [], range: text.utf16NSRange(from: index ..< text.endIndex)) - return replacer(block) + for match in matches { + let content = readContent(from: openTag, withClosingRange: match.range, in: text) + + if tagsAreBalanced(in: content) { + return match.range + } + } + + return nil } - // MARK: - Regex Match Processing Logic + /// Determines if there are an equal number of opening and closing block tags in the provided text. + /// - Parameters: + /// - text: The string to test assumes that a block with an even number represents a valid block sequence. + /// - Returns: A boolean where true represents an equal number of opening and closing block tags of the desired type + /// + func tagsAreBalanced(in text: String) -> Bool { + + let range = text.utf16NSRange(from: text.startIndex ..< text.endIndex) + let openTags = openTagRegex.matches(in: text, options: [], range: range) + let closingTags = closingTagRegex.matches(in: text, options: [], range: range) - /// Obtains the attributes from a block match. + return openTags.count == closingTags.count + } + + /// Obtains the block attributes from a regex match. + /// - Parameters: + /// - match: The `NSTextCheckingResult` from a successful regex detection of an opening block tag + /// - text: The string that the following parameter is found in. + /// - Returns: A JSON dictionary of the block attributes /// - private func readAttributes(from match: NSTextCheckingResult, in text: String) -> [String: Any] { + func readAttributes(from match: NSTextCheckingResult, in text: String) -> [String: Any] { guard let attributesText = match.captureGroup(in: CaptureGroups.attributes.rawValue, text: text), let data = attributesText.data(using: .utf8 ), let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments), @@ -98,13 +178,22 @@ private extension GutenbergBlockProcessor { return jsonDictionary } - /// Obtains the block content from a block match. + /// Obtains the block content from a regex match and range. + /// - Parameters: + /// - match: The `NSTextCheckingResult` from a successful regex detection of an opening block tag + /// - closingRange: The `NSRange` of the closing block tag + /// - text: The string that the following parameters are found in. + /// - Returns: The content between the opening and closing tags of a block /// - private func readContent(from match: NSTextCheckingResult, in text: String) -> String { - guard let content = match.captureGroup(in: CaptureGroups.content.rawValue, text: text) else { + func readContent(from match: NSTextCheckingResult, withClosingRange closingRange: NSRange, in text: String) -> String { + guard let index = text.indexFromLocation(match.range.upperBound) else { + return "" + } + + guard let closingBound = text.indexFromLocation(closingRange.lowerBound) else { return "" } - return content + return String(text[index.. String? - - let name: String - - private enum CaptureGroups: Int { - case all = 0 - case name - case attributes - - static let allValues: [CaptureGroups] = [.all, .name, .attributes] - } - - // MARK: - Parsing & processing properties - private let replacer: Replacer - - // MARK: - Initializers - - public init(for blockName: String, replacer: @escaping Replacer) { - self.name = blockName - self.replacer = replacer - } - - /// Regular expression to detect attributes of the opening tag of a block - /// Capture groups: - /// - /// 1. The block id - /// 2. The block attributes - /// - var openTagRegex: NSRegularExpression { - let pattern = "\\" - return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) - } - - /// Regular expression to detect the closing tag of a block - /// - var closingTagRegex: NSRegularExpression { - let pattern = "\\" - return try! NSRegularExpression(pattern: pattern, options: .caseInsensitive) - } - - // MARK: - Processing - - /// Processes the block and for any needed replacements from a given opening tag match. - /// - Parameters: - /// - text: The string that the following parameter is found in. - /// - Returns: The resulting string after the necessary replacements have occured - /// - public func process(_ text: String) -> String { - let matches = openTagRegex.matches(in: text, options: [], range: text.utf16NSRange(from: text.startIndex ..< text.endIndex)) - var replacements = [(NSRange, String)]() - - var lastReplacementBound = 0 - for match in matches { - if match.range.lowerBound >= lastReplacementBound, let replacement = process(match, in: text) { - replacements.append(replacement) - lastReplacementBound = replacement.0.upperBound - } - } - let resultText = replace(replacements, in: text) - return resultText - } - - /// Replaces the - /// - Parameters: - /// - replacements: An array of tuples representing first a range of text that needs to be replaced then the string to replace - /// - text: The string to perform the replacements on - /// - func replace(_ replacements: [(NSRange, String)], in text: String) -> String { - let mutableString = NSMutableString(string: text) - var offset = 0 - for (range, replacement) in replacements { - let lengthBefore = mutableString.length - let offsetRange = NSRange(location: range.location + offset, length: range.length) - mutableString.replaceCharacters(in: offsetRange, with: replacement) - let lengthAfter = mutableString.length - offset += (lengthAfter - lengthBefore) - } - return mutableString as String - } -} -// MARK: - Regex Match Processing Logic - -private extension GutenbergBlockTreeProcessor { - /// Processes the block and for any needed replacements from a given opening tag match. - /// - Parameters: - /// - match: The match reperesenting an opening block tag - /// - text: The string that the following parameter is found in. - /// - Returns: Any necessary replacements within the provided string - /// - private func process(_ match: NSTextCheckingResult, in text: String) -> (NSRange, String)? { - - var result: (NSRange, String)? = nil - if let closingRange = locateClosingTag(forMatch: match, in: text) { - let attributes = readAttributes(from: match, in: text) - let content = readContent(from: match, withClosingRange: closingRange, in: text) - let parsedContent = process(content) // Recurrsively parse nested blocks and process those seperatly - let block = GutenbergBlock(name: name, attributes: attributes, content: parsedContent) - - if let replacement = replacer(block) { - let length = closingRange.upperBound - match.range.lowerBound - let range = NSRange(location: match.range.lowerBound, length: length) - result = (range, replacement) - } - } - - return result - } - - /// Determines the location of the closing block tag for the matching open tag - /// - Parameters: - /// - openTag: The match reperesenting an opening block tag - /// - text: The string that the following parameter is found in. - /// - Returns: The Range of the closing tag for the block - /// - func locateClosingTag(forMatch openTag: NSTextCheckingResult, in text: String) -> NSRange? { - guard let index = text.indexFromLocation(openTag.range.upperBound) else { - return nil - } - - let matches = closingTagRegex.matches(in: text, options: [], range: text.utf16NSRange(from: index ..< text.endIndex)) - - for match in matches { - let content = readContent(from: openTag, withClosingRange: match.range, in: text) - - if tagsAreBalanced(in: content) { - return match.range - } - } - - return nil - } - - /// Determines if there are an equal number of opening and closing block tags in the provided text. - /// - Parameters: - /// - text: The string to test assumes that a block with an even number represents a valid block sequence. - /// - Returns: A boolean where true represents an equal number of opening and closing block tags of the desired type - /// - func tagsAreBalanced(in text: String) -> Bool { - - let range = text.utf16NSRange(from: text.startIndex ..< text.endIndex) - let openTags = openTagRegex.matches(in: text, options: [], range: range) - let closingTags = closingTagRegex.matches(in: text, options: [], range: range) - - return openTags.count == closingTags.count - } - - /// Obtains the block attributes from a regex match. - /// - Parameters: - /// - match: The `NSTextCheckingResult` from a successful regex detection of an opening block tag - /// - text: The string that the following parameter is found in. - /// - Returns: A JSON dictionary of the block attributes - /// - func readAttributes(from match: NSTextCheckingResult, in text: String) -> [String: Any] { - guard let attributesText = match.captureGroup(in: CaptureGroups.attributes.rawValue, text: text), - let data = attributesText.data(using: .utf8 ), - let json = try? JSONSerialization.jsonObject(with: data, options: .allowFragments), - let jsonDictionary = json as? [String: Any] else { - return [:] - } - - return jsonDictionary - } - - /// Obtains the block content from a regex match and range. - /// - Parameters: - /// - match: The `NSTextCheckingResult` from a successful regex detection of an opening block tag - /// - closingRange: The `NSRange` of the closing block tag - /// - text: The string that the following parameters are found in. - /// - Returns: The content between the opening and closing tags of a block - /// - func readContent(from match: NSTextCheckingResult, withClosingRange closingRange: NSRange, in text: String) -> String { - guard let index = text.indexFromLocation(match.range.upperBound) else { - return "" - } - - guard let closingBound = text.indexFromLocation(closingRange.lowerBound) else { - return "" - } - - return String(text[index..