Skip to content

Commit c88bbce

Browse files
authored
Markdown: Add title property (#28)
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.
1 parent 043ddad commit c88bbce

18 files changed

Lines changed: 195 additions & 23 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
.DS_Store
2+
/build
23
/.build
34
/.swiftpm
45
/*.xcodeproj

Sources/Ink/API/Markdown.swift

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,45 @@ public struct Markdown {
1515
/// The HTML representation of the Markdown, ready to
1616
/// be rendered in a web browser.
1717
public var html: String
18+
/// The inferred title of the document, from any top-level
19+
/// heading found when parsing. If the Markdown text contained
20+
/// two top-level headings, then this property will contain
21+
/// the first one. Note that this property does not take modifiers
22+
/// into acccount.
23+
public var title: String? {
24+
get { makeTitle() }
25+
set { overrideTitle(with: newValue) }
26+
}
1827
/// Any metadata values found at the top of the Markdown
1928
/// document. See this project's README for more information.
2029
public var metadata: [String : String]
30+
31+
private let titleHeading: Heading?
32+
private var titleStorage = TitleStorage()
33+
34+
internal init(html: String,
35+
titleHeading: Heading?,
36+
metadata: [String : String]) {
37+
self.html = html
38+
self.titleHeading = titleHeading
39+
self.metadata = metadata
40+
}
41+
}
42+
43+
private extension Markdown {
44+
final class TitleStorage {
45+
var title: String?
46+
}
47+
48+
mutating func overrideTitle(with title: String?) {
49+
let storage = TitleStorage()
50+
storage.title = title
51+
titleStorage = storage
52+
}
53+
54+
func makeTitle() -> String? {
55+
if let stored = titleStorage.title { return stored }
56+
titleStorage.title = titleHeading?.plainText()
57+
return titleStorage.title
58+
}
2159
}

Sources/Ink/API/MarkdownParser.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public struct MarkdownParser {
4343
var reader = Reader(string: markdown)
4444
var fragments = [ParsedFragment]()
4545
var urlsByName = [String : URL]()
46+
var titleHeading: Heading?
4647
var metadata: Metadata?
4748

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

6970
let fragment = try makeFragment(using: type.readOrRewind, reader: &reader)
7071
fragments.append(fragment)
72+
73+
if titleHeading == nil, let heading = fragment.fragment as? Heading {
74+
if heading.level == 1 {
75+
titleHeading = heading
76+
}
77+
}
7178
} catch {
7279
let paragraph = makeFragment(using: Paragraph.read, reader: &reader)
7380
fragments.append(paragraph)
@@ -88,6 +95,7 @@ public struct MarkdownParser {
8895

8996
return Markdown(
9097
html: html,
98+
titleHeading: titleHeading,
9199
metadata: metadata?.values ?? [:]
92100
)
93101
}

Sources/Ink/Internal/Blockquote.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,8 @@ internal struct Blockquote: Fragment {
3636
let body = text.html(usingURLs: urls, modifiers: modifiers)
3737
return "<blockquote><p>\(body)</p></blockquote>"
3838
}
39+
40+
func plainText() -> String {
41+
text.plainText()
42+
}
3943
}

Sources/Ink/Internal/CodeBlock.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,4 +52,8 @@ internal struct CodeBlock: Fragment {
5252
let languageClass = language.isEmpty ? "" : " class=\"language-\(language)\""
5353
return "<pre><code\(languageClass)>\(code)</code></pre>"
5454
}
55+
56+
func plainText() -> String {
57+
code
58+
}
5559
}

Sources/Ink/Internal/FormattedText.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* MIT license, see LICENSE file for details
55
*/
66

7-
internal struct FormattedText: Readable, HTMLConvertible {
7+
internal struct FormattedText: Readable, HTMLConvertible, PlainTextConvertible {
88
private var components = [Component]()
99

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

2828
func html(usingURLs urls: NamedURLCollection,
2929
modifiers: ModifierCollection) -> String {
30-
return components.reduce(into: "") { string, component in
30+
components.reduce(into: "") { string, component in
3131
switch component {
3232
case .linebreak:
3333
string.append("<br>")
@@ -48,6 +48,21 @@ internal struct FormattedText: Readable, HTMLConvertible {
4848
}
4949
}
5050

51+
func plainText() -> String {
52+
components.reduce(into: "") { string, component in
53+
switch component {
54+
case .linebreak:
55+
string.append("\n")
56+
case .text(let text):
57+
string.append(String(text))
58+
case .styleMarker:
59+
break
60+
case .fragment(let fragment, _):
61+
string.append(fragment.plainText())
62+
}
63+
}
64+
}
65+
5166
mutating func append(_ text: FormattedText, separator: Substring = "") {
5267
let separator = separator.isEmpty ? [] : [Component.text(separator)]
5368
components += separator + text.components

Sources/Ink/Internal/Fragment.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
* MIT license, see LICENSE file for details
55
*/
66

7-
internal typealias Fragment = Readable & Modifiable & HTMLConvertible
7+
internal typealias Fragment = Readable & Modifiable & HTMLConvertible & PlainTextConvertible

Sources/Ink/Internal/HTML.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ internal struct HTML: Fragment {
5353

5454
func html(usingURLs urls: NamedURLCollection,
5555
modifiers: ModifierCollection) -> String {
56-
return String(string)
56+
String(string)
57+
}
58+
59+
func plainText() -> String {
60+
// Since we want to strip all HTML from plain text output,
61+
// there is nothing to return here, just an empty string.
62+
""
5763
}
5864
}
5965

Sources/Ink/Internal/Heading.swift

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66

77
internal struct Heading: Fragment {
88
var modifierTarget: Modifier.Target { .headings }
9+
var level: Int
910

10-
private var level: Int
1111
private var text: FormattedText
1212

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

2222
func html(usingURLs urls: NamedURLCollection,
2323
modifiers: ModifierCollection) -> String {
24-
var body = text.html(usingURLs: urls, modifiers: modifiers)
24+
let body = stripTrailingMarkers(
25+
from: text.html(usingURLs: urls, modifiers: modifiers)
26+
)
2527

26-
if !body.isEmpty {
27-
let lastCharacterIndex = body.index(before: body.endIndex)
28-
var trimIndex = lastCharacterIndex
28+
let tagName = "h\(level)"
29+
return "<\(tagName)>\(body)</\(tagName)>"
30+
}
31+
32+
func plainText() -> String {
33+
stripTrailingMarkers(from: text.plainText())
34+
}
35+
}
2936

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

34-
if trimIndex != lastCharacterIndex {
35-
body = String(body[..<trimIndex])
36-
}
41+
let lastCharacterIndex = text.index(before: text.endIndex)
42+
var trimIndex = lastCharacterIndex
43+
44+
while text[trimIndex] == "#", trimIndex != text.startIndex {
45+
trimIndex = text.index(before: trimIndex)
3746
}
3847

39-
let tagName = "h\(level)"
40-
return "<\(tagName)>\(body)</\(tagName)>"
48+
if trimIndex != lastCharacterIndex {
49+
return String(text[..<trimIndex])
50+
}
51+
52+
return text
4153
}
4254
}

Sources/Ink/Internal/HorizontalLine.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ internal struct HorizontalLine: Fragment {
1919

2020
func html(usingURLs urls: NamedURLCollection,
2121
modifiers: ModifierCollection) -> String {
22-
return "<hr>"
22+
"<hr>"
23+
}
24+
25+
func plainText() -> String {
26+
// Since we want to strip all HTML from plain text output,
27+
// there is nothing to return here, just an empty string.
28+
""
2329
}
2430
}

0 commit comments

Comments
 (0)