diff --git a/Modules/Sources/WordPressKitModels/NSString+Summary.swift b/Modules/Sources/WordPressKitModels/NSString+Summary.swift index 696ecd7aea3b..931972c7cb46 100644 --- a/Modules/Sources/WordPressKitModels/NSString+Summary.swift +++ b/Modules/Sources/WordPressKitModels/NSString+Summary.swift @@ -18,4 +18,11 @@ extension NSString { public func wpkit_makePlainText() -> String { makePlainText() } + + /// Converts an HTML excerpt into single-line plain text, turning `
` + /// tags into spaces so adjacent words don't run together. + @objc + public func wpkit_makeSingleLinePlainText() -> String { + GutenbergExcerptGenerator.singleLinePlainText(from: (self as String)) + } } diff --git a/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m b/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m index d9d23ec2850d..352e55949a0d 100644 --- a/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m +++ b/Modules/Sources/WordPressKitObjC/RemoteReaderPost.m @@ -627,7 +627,7 @@ - (NSString *)postTitleFromPostDictionary:(NSDictionary *)dict { - (NSString *)postSummaryFromPostDictionary:(NSDictionary *)dict orPostContent:(NSString *)content { NSString *summary = [self stringOrEmptyString:[dict stringForKey:PostRESTKeyExcerpt]]; summary = [self formatSummary:summary]; - if (!summary) { + if (summary.length == 0) { summary = [self createSummaryFromContent:content]; } return summary; @@ -670,14 +670,14 @@ - (NSArray *)slugsFromDiscoverPostTaxonomies:(NSArray *)discoverPostTaxonomies /** Formats a post's summary. The excerpts provided by the REST API contain HTML and have some extra content appened to the end. - HTML is stripped and the extra bit is removed. + HTML is stripped and the extra bit is removed. `
` tags become spaces so adjacent words don't run together. @param summary The summary to format. @return The formatted summary. */ - (NSString *)formatSummary:(NSString *)summary { - summary = [self makePlainText:summary]; + summary = [summary wpkit_makeSingleLinePlainText]; NSString *continueReading = NSLocalizedString(@"Continue reading", @"Part of a prompt suggesting that there is more content for the user to read."); continueReading = [NSString stringWithFormat:@"%@ →", continueReading]; diff --git a/Modules/Sources/WordPressShared/Utility/GutenbergExcerptGenerator.swift b/Modules/Sources/WordPressShared/Utility/GutenbergExcerptGenerator.swift index 2e67c688ff15..e965ca749e01 100644 --- a/Modules/Sources/WordPressShared/Utility/GutenbergExcerptGenerator.swift +++ b/Modules/Sources/WordPressShared/Utility/GutenbergExcerptGenerator.swift @@ -27,19 +27,7 @@ public struct GutenbergExcerptGenerator { } let paragraph = String(content[paragraphTag.upperBound..` runs to spaces so words don't run together, then remove - // any remaining tags and shortcodes. - let withoutBreaks = replacingMatches(of: lineBreakRegex, in: paragraph, withTemplate: " ") - let stripped = replacingMatches(of: tagOrShortcodeRegex, in: withoutBreaks, withTemplate: "") - - // Decode entities and collapse every whitespace run into a single space, - // yielding single-line plain text. - let text = stripped - .stringByDecodingXMLCharacters() - .components(separatedBy: collapsibleWhitespace) - .filter { !$0.isEmpty } - .joined(separator: " ") + let text = singleLinePlainText(from: paragraph) // Truncate if needed. if text.count <= maxLength { @@ -53,6 +41,25 @@ public struct GutenbergExcerptGenerator { return truncated + "…" } + /// Converts HTML into single-line plain text. Unlike a plain tag stripper, + /// `
` runs become spaces so adjacent words don't run together. + /// Remaining tags and shortcodes are removed, entities are decoded, and + /// whitespace runs collapse into single spaces. + public static func singleLinePlainText(from html: String) -> String { + // Convert `
` runs to spaces so words don't run together, then remove + // any remaining tags and shortcodes. + let withoutBreaks = replacingMatches(of: lineBreakRegex, in: html, withTemplate: " ") + let stripped = replacingMatches(of: tagOrShortcodeRegex, in: withoutBreaks, withTemplate: "") + + // Decode entities and collapse every whitespace run into a single space, + // yielding single-line plain text. + return stripped + .stringByDecodingXMLCharacters() + .components(separatedBy: collapsibleWhitespace) + .filter { !$0.isEmpty } + .joined(separator: " ") + } + /// Returns the range of the first match of `regex` in `string`, or `nil`. private static func firstMatch(of regex: NSRegularExpression?, in string: String) -> Range? { guard let regex else { return nil } diff --git a/Tests/WordPressKitTests/WordPressKitTests/Tests/RemoteReaderPostTests.m b/Tests/WordPressKitTests/WordPressKitTests/Tests/RemoteReaderPostTests.m index 05c35eb3becd..771b7f2c6707 100644 --- a/Tests/WordPressKitTests/WordPressKitTests/Tests/RemoteReaderPostTests.m +++ b/Tests/WordPressKitTests/WordPressKitTests/Tests/RemoteReaderPostTests.m @@ -105,6 +105,21 @@ - (void)testSummaryIsPlainText { XCTAssertTrue([str isEqualToString:sanatizedStr], @"The post summary was not plain text."); } +- (void)testSummaryConvertsLineBreaksToSpaces { + RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; + NSDictionary *dict = @{@"excerpt": @"

Yes,
look behind

"}; + NSString *summary = [remoteReaderPost postSummaryFromPostDictionary:dict orPostContent:@""]; + XCTAssertEqualObjects(summary, @"Yes, look behind", @"A
in the API excerpt should become a space instead of running words together."); +} + +- (void)testSummaryFallsBackToContentWhenExcerptIsEmpty { + RemoteReaderPost *remoteReaderPost = [RemoteReaderPost alloc]; + NSDictionary *dict = @{@"excerpt": @""}; + NSString *content = @"

Generated from the post content.

"; + NSString *summary = [remoteReaderPost postSummaryFromPostDictionary:dict orPostContent:content]; + XCTAssertEqualObjects(summary, @"Generated from the post content.", @"An empty API excerpt should fall back to a summary generated from the post content."); +} + - (void)testSiteIsAtomic { NSString *key = @"site_is_atomic";