Skip to content

Commit

Permalink
Markdown: Add title property (#28)
Browse files Browse the repository at this point in the history
This change adds a `title` property to the `Markdown` struct, which when
accessed is lazily evaluated to compute a plain text version of the first
top-level heading (H1) found in the Markdown text.

To make this happen, a `PlainTextConvertible` protocol is introduced,
which is also made a requirement of `Fragment`. A best effort is made
to convert each fragment into a reasonable plain text representation,
even if many of them will never be used as the implementation currently
stands (for example, lists can’t be placed inside headings). It still
felt worth it to do a proper implementation in case we ever make this
a public API for some reason.

This lets tools built on top of Ink extract a title for the parsed Markdown
document without doing additional string parsing.
  • Loading branch information
JohnSundell authored Dec 6, 2019
1 parent 043ddad commit c88bbce
Show file tree
Hide file tree
Showing 18 changed files with 195 additions and 23 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
.DS_Store
/build
/.build
/.swiftpm
/*.xcodeproj
38 changes: 38 additions & 0 deletions Sources/Ink/API/Markdown.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,45 @@ public struct Markdown {
/// The HTML representation of the Markdown, ready to
/// be rendered in a web browser.
public var html: String
/// The inferred title of the document, from any top-level
/// heading found when parsing. If the Markdown text contained
/// two top-level headings, then this property will contain
/// the first one. Note that this property does not take modifiers
/// into acccount.
public var title: String? {
get { makeTitle() }
set { overrideTitle(with: newValue) }
}
/// Any metadata values found at the top of the Markdown
/// document. See this project's README for more information.
public var metadata: [String : String]

private let titleHeading: Heading?
private var titleStorage = TitleStorage()

internal init(html: String,
titleHeading: Heading?,
metadata: [String : String]) {
self.html = html
self.titleHeading = titleHeading
self.metadata = metadata
}
}

private extension Markdown {
final class TitleStorage {
var title: String?
}

mutating func overrideTitle(with title: String?) {
let storage = TitleStorage()
storage.title = title
titleStorage = storage
}

func makeTitle() -> String? {
if let stored = titleStorage.title { return stored }
titleStorage.title = titleHeading?.plainText()
return titleStorage.title
}
}
8 changes: 8 additions & 0 deletions Sources/Ink/API/MarkdownParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public struct MarkdownParser {
var reader = Reader(string: markdown)
var fragments = [ParsedFragment]()
var urlsByName = [String : URL]()
var titleHeading: Heading?
var metadata: Metadata?

while !reader.didReachEnd {
Expand All @@ -68,6 +69,12 @@ public struct MarkdownParser {

let fragment = try makeFragment(using: type.readOrRewind, reader: &reader)
fragments.append(fragment)

if titleHeading == nil, let heading = fragment.fragment as? Heading {
if heading.level == 1 {
titleHeading = heading
}
}
} catch {
let paragraph = makeFragment(using: Paragraph.read, reader: &reader)
fragments.append(paragraph)
Expand All @@ -88,6 +95,7 @@ public struct MarkdownParser {

return Markdown(
html: html,
titleHeading: titleHeading,
metadata: metadata?.values ?? [:]
)
}
Expand Down
4 changes: 4 additions & 0 deletions Sources/Ink/Internal/Blockquote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ internal struct Blockquote: Fragment {
let body = text.html(usingURLs: urls, modifiers: modifiers)
return "<blockquote><p>\(body)</p></blockquote>"
}

func plainText() -> String {
text.plainText()
}
}
4 changes: 4 additions & 0 deletions Sources/Ink/Internal/CodeBlock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@ internal struct CodeBlock: Fragment {
let languageClass = language.isEmpty ? "" : " class=\"language-\(language)\""
return "<pre><code\(languageClass)>\(code)</code></pre>"
}

func plainText() -> String {
code
}
}
19 changes: 17 additions & 2 deletions Sources/Ink/Internal/FormattedText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* MIT license, see LICENSE file for details
*/

internal struct FormattedText: Readable, HTMLConvertible {
internal struct FormattedText: Readable, HTMLConvertible, PlainTextConvertible {
private var components = [Component]()

static func read(using reader: inout Reader) -> Self {
Expand All @@ -27,7 +27,7 @@ internal struct FormattedText: Readable, HTMLConvertible {

func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
return components.reduce(into: "") { string, component in
components.reduce(into: "") { string, component in
switch component {
case .linebreak:
string.append("<br>")
Expand All @@ -48,6 +48,21 @@ internal struct FormattedText: Readable, HTMLConvertible {
}
}

func plainText() -> String {
components.reduce(into: "") { string, component in
switch component {
case .linebreak:
string.append("\n")
case .text(let text):
string.append(String(text))
case .styleMarker:
break
case .fragment(let fragment, _):
string.append(fragment.plainText())
}
}
}

mutating func append(_ text: FormattedText, separator: Substring = "") {
let separator = separator.isEmpty ? [] : [Component.text(separator)]
components += separator + text.components
Expand Down
2 changes: 1 addition & 1 deletion Sources/Ink/Internal/Fragment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
* MIT license, see LICENSE file for details
*/

internal typealias Fragment = Readable & Modifiable & HTMLConvertible
internal typealias Fragment = Readable & Modifiable & HTMLConvertible & PlainTextConvertible
8 changes: 7 additions & 1 deletion Sources/Ink/Internal/HTML.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,13 @@ internal struct HTML: Fragment {

func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
return String(string)
String(string)
}

func plainText() -> String {
// Since we want to strip all HTML from plain text output,
// there is nothing to return here, just an empty string.
""
}
}

Expand Down
38 changes: 25 additions & 13 deletions Sources/Ink/Internal/Heading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

internal struct Heading: Fragment {
var modifierTarget: Modifier.Target { .headings }
var level: Int

private var level: Int
private var text: FormattedText

static func read(using reader: inout Reader) throws -> Heading {
Expand All @@ -21,22 +21,34 @@ internal struct Heading: Fragment {

func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
var body = text.html(usingURLs: urls, modifiers: modifiers)
let body = stripTrailingMarkers(
from: text.html(usingURLs: urls, modifiers: modifiers)
)

if !body.isEmpty {
let lastCharacterIndex = body.index(before: body.endIndex)
var trimIndex = lastCharacterIndex
let tagName = "h\(level)"
return "<\(tagName)>\(body)</\(tagName)>"
}

func plainText() -> String {
stripTrailingMarkers(from: text.plainText())
}
}

while body[trimIndex] == "#", trimIndex != body.startIndex {
trimIndex = body.index(before: trimIndex)
}
private extension Heading {
func stripTrailingMarkers(from text: String) -> String {
guard !text.isEmpty else { return text }

if trimIndex != lastCharacterIndex {
body = String(body[..<trimIndex])
}
let lastCharacterIndex = text.index(before: text.endIndex)
var trimIndex = lastCharacterIndex

while text[trimIndex] == "#", trimIndex != text.startIndex {
trimIndex = text.index(before: trimIndex)
}

let tagName = "h\(level)"
return "<\(tagName)>\(body)</\(tagName)>"
if trimIndex != lastCharacterIndex {
return String(text[..<trimIndex])
}

return text
}
}
8 changes: 7 additions & 1 deletion Sources/Ink/Internal/HorizontalLine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ internal struct HorizontalLine: Fragment {

func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
return "<hr>"
"<hr>"
}

func plainText() -> String {
// Since we want to strip all HTML from plain text output,
// there is nothing to return here, just an empty string.
""
}
}
4 changes: 4 additions & 0 deletions Sources/Ink/Internal/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@ internal struct Image: Fragment {

return "<img src=\"\(url)\"\(alt)/>"
}

func plainText() -> String {
link.plainText()
}
}
4 changes: 4 additions & 0 deletions Sources/Ink/Internal/InlineCode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,8 @@ struct InlineCode: Fragment {
modifiers: ModifierCollection) -> String {
return "<code>\(code)</code>"
}

func plainText() -> String {
code
}
}
4 changes: 4 additions & 0 deletions Sources/Ink/Internal/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ internal struct Link: Fragment {
let title = text.html(usingURLs: urls, modifiers: modifiers)
return "<a href=\"\(url)\">\(title)</a>"
}

func plainText() -> String {
text.plainText()
}
}

extension Link {
Expand Down
14 changes: 14 additions & 0 deletions Sources/Ink/Internal/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,20 @@ internal struct List: Fragment {

return "<\(tagName)\(startAttribute)>\(body)</\(tagName)>"
}

func plainText() -> String {
var isFirst = true

return items.reduce(into: "") { string, item in
if isFirst {
isFirst = false
} else {
string.append(", ")
}

string.append(item.text.plainText())
}
}
}

private extension List {
Expand Down
4 changes: 4 additions & 0 deletions Sources/Ink/Internal/Paragraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@ internal struct Paragraph: Fragment {
let body = text.html(usingURLs: urls, modifiers: modifiers)
return "<p>\(body)</p>"
}

func plainText() -> String {
text.plainText()
}
}
3 changes: 3 additions & 0 deletions Sources/Ink/Internal/PlainTextConvertible.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
internal protocol PlainTextConvertible {
func plainText() -> String
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import XCTest
import Ink

final class MetadataTests: XCTestCase {
final class MarkdownTests: XCTestCase {
func testParsingMetadata() {
let markdown = MarkdownParser().parse("""
---
Expand Down Expand Up @@ -66,15 +66,60 @@ final class MetadataTests: XCTestCase {
XCTAssertEqual(markdown.metadata, [:])
XCTAssertEqual(markdown.html, "<h1>Title</h1>")
}

func testPlainTextTitle() {
let markdown = MarkdownParser().parse("""
# Hello, world!
""")

XCTAssertEqual(markdown.title, "Hello, world!")
}

func testRemovingTrailingMarkersFromTitle() {
let markdown = MarkdownParser().parse("""
# Hello, world! ####
""")

XCTAssertEqual(markdown.title, "Hello, world!")
}

func testConvertingFormattedTitleTextToPlainText() {
let markdown = MarkdownParser().parse("""
# *Italic* **Bold** [Link](url) ![Image](url) `Code`
""")

XCTAssertEqual(markdown.title, "Italic Bold Link Image Code")
}

func testTreatingFirstHeadingAsTitle() {
let markdown = MarkdownParser().parse("""
# Title 1
# Title 2
## Title 3
""")

XCTAssertEqual(markdown.title, "Title 1")
}

func testOverridingTitle() {
var markdown = MarkdownParser().parse("# Title")
markdown.title = "Title 2"
XCTAssertEqual(markdown.title, "Title 2")
}
}

extension MetadataTests {
static var allTests: Linux.TestList<MetadataTests> {
extension MarkdownTests {
static var allTests: Linux.TestList<MarkdownTests> {
return [
("testParsingMetadata", testParsingMetadata),
("testDiscardingEmptyMetadataValues", testDiscardingEmptyMetadataValues),
("testMergingOrphanMetadataValueIntoPreviousOne", testMergingOrphanMetadataValueIntoPreviousOne),
("testMissingMetadata", testMissingMetadata)
("testMissingMetadata", testMissingMetadata),
("testPlainTextTitle", testPlainTextTitle),
("testRemovingTrailingMarkersFromTitle", testRemovingTrailingMarkersFromTitle),
("testConvertingFormattedTitleTextToPlainText", testConvertingFormattedTitleTextToPlainText),
("testTreatingFirstHeadingAsTitle", testTreatingFirstHeadingAsTitle),
("testOverridingTitle", testOverridingTitle)
]
}
}
2 changes: 1 addition & 1 deletion Tests/InkTests/XCTestManifests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ public func allTests() -> [Linux.TestCase] {
Linux.makeTestCase(using: ImageTests.allTests),
Linux.makeTestCase(using: LinkTests.allTests),
Linux.makeTestCase(using: ListTests.allTests),
Linux.makeTestCase(using: MetadataTests.allTests),
Linux.makeTestCase(using: MarkdownTests.allTests),
Linux.makeTestCase(using: ModifierTests.allTests),
Linux.makeTestCase(using: TextFormattingTests.allTests)
]
Expand Down

0 comments on commit c88bbce

Please sign in to comment.