Skip to content

Commit bd223a2

Browse files
christiankmChristian Mitteldorfjohn-muellerJohnSundell
authored
Add support for Markdown Tables (#44)
This change makes Ink support Markdown Tables Co-authored-by: Christian Mitteldorf <cmit@danskebank.dk> Co-authored-by: John Mueller <jmuellerokc@gmail.com> Co-authored-by: John Sundell <john@sundell.co>
1 parent 878fd89 commit bd223a2

9 files changed

Lines changed: 465 additions & 11 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ Ink supports the following Markdown features:
168168
- Horizontal lines can be placed using either three asterisks (`***`) or three dashes (`---`) on a new line.
169169
- HTML can be inlined both at the root level, and within text paragraphs.
170170
- Blockquotes can be created by placing a greater-than arrow at the start of a line, like this: `> This is a blockquote`.
171+
- Tables can be created using the following syntax (the line consisting of dashes (`-`) can be omitted to create a table without a header row):
172+
```
173+
| Header | Header 2 |
174+
| ------ | -------- |
175+
| Row 1 | Cell 1 |
176+
| Row 2 | Cell 2 |
177+
```
171178

172179
Please note that, being a very young implementation, Ink does not fully support all Markdown specs, such as [CommonMark](https://commonmark.org). Ink definitely aims to cover as much ground as possible, and to include support for the most commonly used Markdown features, but if complete CommonMark compatibility is what you’re looking for — then you might want to check out tools like [CMark](https://github.com/commonmark/cmark).
173180

Sources/Ink/API/MarkdownParser.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ private extension MarkdownParser {
127127
"*" where character == nextCharacter:
128128
return HorizontalLine.self
129129
case "-", "*", "+", \.isNumber: return List.self
130+
case "|": return Table.self
130131
default: return Paragraph.self
131132
}
132133
}

Sources/Ink/API/Modifier.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,6 @@ public extension Modifier {
5252
case links
5353
case lists
5454
case paragraphs
55+
case tables
5556
}
5657
}

Sources/Ink/Internal/FormattedText.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,18 @@ internal struct FormattedText: Readable, HTMLConvertible, PlainTextConvertible {
88
private var components = [Component]()
99

1010
static func read(using reader: inout Reader) -> Self {
11-
read(using: &reader, terminator: nil)
11+
read(using: &reader, terminators: [])
1212
}
1313

1414
static func readLine(using reader: inout Reader) -> Self {
15-
let text = read(using: &reader, terminator: "\n")
15+
let text = read(using: &reader, terminators: ["\n"])
1616
if !reader.didReachEnd { reader.advanceIndex() }
1717
return text
1818
}
1919

2020
static func read(using reader: inout Reader,
21-
terminator: Character?) -> Self {
22-
var parser = Parser(reader: reader, terminator: terminator)
21+
terminators: Set<Character>) -> Self {
22+
var parser = Parser(reader: reader, terminators: terminators)
2323
parser.parse()
2424
reader = parser.reader
2525
return parser.text
@@ -79,15 +79,15 @@ private extension FormattedText {
7979

8080
struct Parser {
8181
var reader: Reader
82-
let terminator: Character?
82+
let terminators: Set<Character>
8383
var text = FormattedText()
8484
var pendingTextRange: Range<String.Index>
8585
var activeStyles = Set<TextStyle>()
8686
var activeStyleMarkers = [TextStyleMarker]()
8787

88-
init(reader: Reader, terminator: Character?) {
88+
init(reader: Reader, terminators: Set<Character>) {
8989
self.reader = reader
90-
self.terminator = terminator
90+
self.terminators = terminators
9191
self.pendingTextRange = reader.currentIndex..<reader.endIndex
9292
}
9393

@@ -96,8 +96,8 @@ private extension FormattedText {
9696

9797
while !reader.didReachEnd {
9898
do {
99-
if let terminator = terminator, reader.previousCharacter != "\\" {
100-
guard reader.currentCharacter != terminator else {
99+
if !terminators.isEmpty, reader.previousCharacter != "\\" {
100+
guard !terminators.contains(reader.currentCharacter) else {
101101
break
102102
}
103103
}

Sources/Ink/Internal/Heading.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ internal struct Heading: Fragment {
1414
let level = reader.readCount(of: "#")
1515
try require(level > 0 && level < 7)
1616
try reader.readWhitespaces()
17-
let text = FormattedText.read(using: &reader, terminator: "\n")
17+
let text = FormattedText.read(using: &reader, terminators: ["\n"])
1818

1919
return Heading(level: level, text: text)
2020
}

Sources/Ink/Internal/Link.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ internal struct Link: Fragment {
1212

1313
static func read(using reader: inout Reader) throws -> Link {
1414
try reader.read("[")
15-
let text = FormattedText.read(using: &reader, terminator: "]")
15+
let text = FormattedText.read(using: &reader, terminators: ["]"])
1616
try reader.read("]")
1717

1818
guard !reader.didReachEnd else { throw Reader.Error() }

Sources/Ink/Internal/Table.swift

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/**
2+
* Ink
3+
* Copyright (c) John Sundell 2020
4+
* MIT license, see LICENSE file for details
5+
*/
6+
7+
import Foundation
8+
9+
struct Table: Fragment {
10+
var modifierTarget: Modifier.Target { .tables }
11+
12+
private var header: Row?
13+
private var rows = [Row]()
14+
private var columnCount = 0
15+
private var columnAlignments = [ColumnAlignment]()
16+
17+
static func read(using reader: inout Reader) throws -> Table {
18+
var table = Table()
19+
20+
while !reader.didReachEnd, !reader.currentCharacter.isNewline {
21+
guard reader.currentCharacter == "|" else {
22+
break
23+
}
24+
25+
let row = try reader.readTableRow()
26+
table.rows.append(row)
27+
table.columnCount = max(table.columnCount, row.count)
28+
}
29+
30+
guard !table.rows.isEmpty else { throw Reader.Error() }
31+
table.formHeaderAndColumnAlignmentsIfNeeded()
32+
return table
33+
}
34+
35+
func html(usingURLs urls: NamedURLCollection,
36+
modifiers: ModifierCollection) -> String {
37+
var html = ""
38+
let render: () -> String = { "<table>\(html)</table>" }
39+
40+
if let header = header {
41+
let rowHTML = self.html(
42+
forRow: header,
43+
cellElementName: "th",
44+
urls: urls,
45+
modifiers: modifiers
46+
)
47+
48+
html.append("<thead>\(rowHTML)</thead>")
49+
}
50+
51+
guard !rows.isEmpty else {
52+
return render()
53+
}
54+
55+
html.append("<tbody>")
56+
57+
for row in rows {
58+
let rowHTML = self.html(
59+
forRow: row,
60+
cellElementName: "td",
61+
urls: urls,
62+
modifiers: modifiers
63+
)
64+
65+
html.append(rowHTML)
66+
}
67+
68+
html.append("</tbody>")
69+
return render()
70+
}
71+
72+
func plainText() -> String {
73+
var text = header.map(plainText) ?? ""
74+
75+
for row in rows {
76+
if !text.isEmpty { text.append("\n") }
77+
text.append(plainText(forRow: row))
78+
}
79+
80+
return text
81+
}
82+
}
83+
84+
private extension Table {
85+
typealias Row = [FormattedText]
86+
typealias Cell = FormattedText
87+
88+
static let delimiters: Set<Character> = ["|", "\n"]
89+
static let allowedHeaderCharacters: Set<Character> = ["-", ":"]
90+
91+
enum ColumnAlignment {
92+
case none
93+
case left
94+
case center
95+
case right
96+
97+
var attribute: String {
98+
switch self {
99+
case .none:
100+
return ""
101+
case .left:
102+
return #" align="left""#
103+
case .center:
104+
return #" align="center""#
105+
case .right:
106+
return #" align="right""#
107+
}
108+
}
109+
}
110+
111+
mutating func formHeaderAndColumnAlignmentsIfNeeded() {
112+
guard rows.count > 1 else { return }
113+
guard rows[0].count == rows[1].count else { return }
114+
115+
let textPredicate = Self.allowedHeaderCharacters.contains
116+
var alignments = [ColumnAlignment]()
117+
118+
for cell in rows[1] {
119+
let text = cell.plainText()
120+
121+
guard text.allSatisfy(textPredicate) else {
122+
return
123+
}
124+
125+
alignments.append(parseColumnAlignment(from: text))
126+
}
127+
128+
header = rows[0]
129+
columnAlignments = alignments
130+
rows.removeSubrange(0...1)
131+
}
132+
133+
func parseColumnAlignment(from text: String) -> ColumnAlignment {
134+
switch (text.first, text.last) {
135+
case (":", ":"):
136+
return .center
137+
case (":", _):
138+
return .left
139+
case (_, ":"):
140+
return .right
141+
default:
142+
return .none
143+
}
144+
}
145+
146+
func html(forRow row: Row,
147+
cellElementName: String,
148+
urls: NamedURLCollection,
149+
modifiers: ModifierCollection) -> String {
150+
var html = "<tr>"
151+
152+
for index in 0..<columnCount {
153+
let cell = index < row.count ? row[index] : nil
154+
let contents = cell?.html(usingURLs: urls, modifiers: modifiers)
155+
156+
html.append(htmlForCell(
157+
at: index,
158+
contents: contents ?? "",
159+
elementName: cellElementName
160+
))
161+
}
162+
163+
return html + "</tr>"
164+
}
165+
166+
func htmlForCell(at index: Int, contents: String, elementName: String) -> String {
167+
let alignment = index < columnAlignments.count
168+
? columnAlignments[index]
169+
: .none
170+
171+
let tags = (
172+
opening: "<\(elementName)\(alignment.attribute)>",
173+
closing: "</\(elementName)>"
174+
)
175+
176+
return tags.opening + contents + tags.closing
177+
}
178+
179+
func plainText(forRow row: Row) -> String {
180+
var text = ""
181+
182+
for index in 0..<columnCount {
183+
let cell = index < row.count ? row[index] : nil
184+
if index > 0 { text.append(" | ") }
185+
text.append(cell?.plainText() ?? "")
186+
}
187+
188+
return text + " |"
189+
}
190+
}
191+
192+
private extension Reader {
193+
mutating func readTableRow() throws -> Table.Row {
194+
try readTableDelimiter()
195+
var row = Table.Row()
196+
197+
while !didReachEnd {
198+
let cell = FormattedText.read(
199+
using: &self,
200+
terminators: Table.delimiters
201+
)
202+
203+
try readTableDelimiter()
204+
row.append(cell)
205+
206+
if !didReachEnd, currentCharacter.isNewline {
207+
advanceIndex()
208+
break
209+
}
210+
}
211+
212+
return row
213+
}
214+
215+
mutating func readTableDelimiter() throws {
216+
try read("|")
217+
discardWhitespaces()
218+
}
219+
}

0 commit comments

Comments
 (0)