Skip to content

Commit

Permalink
Add support for Markdown Tables (#44)
Browse files Browse the repository at this point in the history
This change makes Ink support Markdown Tables

Co-authored-by: Christian Mitteldorf <[email protected]>
Co-authored-by: John Mueller <[email protected]>
Co-authored-by: John Sundell <[email protected]>
  • Loading branch information
4 people authored May 17, 2020
1 parent 878fd89 commit bd223a2
Show file tree
Hide file tree
Showing 9 changed files with 465 additions and 11 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ Ink supports the following Markdown features:
- Horizontal lines can be placed using either three asterisks (`***`) or three dashes (`---`) on a new line.
- HTML can be inlined both at the root level, and within text paragraphs.
- Blockquotes can be created by placing a greater-than arrow at the start of a line, like this: `> This is a blockquote`.
- Tables can be created using the following syntax (the line consisting of dashes (`-`) can be omitted to create a table without a header row):
```
| Header | Header 2 |
| ------ | -------- |
| Row 1 | Cell 1 |
| Row 2 | Cell 2 |
```

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).

Expand Down
1 change: 1 addition & 0 deletions Sources/Ink/API/MarkdownParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ private extension MarkdownParser {
"*" where character == nextCharacter:
return HorizontalLine.self
case "-", "*", "+", \.isNumber: return List.self
case "|": return Table.self
default: return Paragraph.self
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Ink/API/Modifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,6 @@ public extension Modifier {
case links
case lists
case paragraphs
case tables
}
}
18 changes: 9 additions & 9 deletions Sources/Ink/Internal/FormattedText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ internal struct FormattedText: Readable, HTMLConvertible, PlainTextConvertible {
private var components = [Component]()

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

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

static func read(using reader: inout Reader,
terminator: Character?) -> Self {
var parser = Parser(reader: reader, terminator: terminator)
terminators: Set<Character>) -> Self {
var parser = Parser(reader: reader, terminators: terminators)
parser.parse()
reader = parser.reader
return parser.text
Expand Down Expand Up @@ -79,15 +79,15 @@ private extension FormattedText {

struct Parser {
var reader: Reader
let terminator: Character?
let terminators: Set<Character>
var text = FormattedText()
var pendingTextRange: Range<String.Index>
var activeStyles = Set<TextStyle>()
var activeStyleMarkers = [TextStyleMarker]()

init(reader: Reader, terminator: Character?) {
init(reader: Reader, terminators: Set<Character>) {
self.reader = reader
self.terminator = terminator
self.terminators = terminators
self.pendingTextRange = reader.currentIndex..<reader.endIndex
}

Expand All @@ -96,8 +96,8 @@ private extension FormattedText {

while !reader.didReachEnd {
do {
if let terminator = terminator, reader.previousCharacter != "\\" {
guard reader.currentCharacter != terminator else {
if !terminators.isEmpty, reader.previousCharacter != "\\" {
guard !terminators.contains(reader.currentCharacter) else {
break
}
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Ink/Internal/Heading.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ internal struct Heading: Fragment {
let level = reader.readCount(of: "#")
try require(level > 0 && level < 7)
try reader.readWhitespaces()
let text = FormattedText.read(using: &reader, terminator: "\n")
let text = FormattedText.read(using: &reader, terminators: ["\n"])

return Heading(level: level, text: text)
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Ink/Internal/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal struct Link: Fragment {

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

guard !reader.didReachEnd else { throw Reader.Error() }
Expand Down
219 changes: 219 additions & 0 deletions Sources/Ink/Internal/Table.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/**
* Ink
* Copyright (c) John Sundell 2020
* MIT license, see LICENSE file for details
*/

import Foundation

struct Table: Fragment {
var modifierTarget: Modifier.Target { .tables }

private var header: Row?
private var rows = [Row]()
private var columnCount = 0
private var columnAlignments = [ColumnAlignment]()

static func read(using reader: inout Reader) throws -> Table {
var table = Table()

while !reader.didReachEnd, !reader.currentCharacter.isNewline {
guard reader.currentCharacter == "|" else {
break
}

let row = try reader.readTableRow()
table.rows.append(row)
table.columnCount = max(table.columnCount, row.count)
}

guard !table.rows.isEmpty else { throw Reader.Error() }
table.formHeaderAndColumnAlignmentsIfNeeded()
return table
}

func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
var html = ""
let render: () -> String = { "<table>\(html)</table>" }

if let header = header {
let rowHTML = self.html(
forRow: header,
cellElementName: "th",
urls: urls,
modifiers: modifiers
)

html.append("<thead>\(rowHTML)</thead>")
}

guard !rows.isEmpty else {
return render()
}

html.append("<tbody>")

for row in rows {
let rowHTML = self.html(
forRow: row,
cellElementName: "td",
urls: urls,
modifiers: modifiers
)

html.append(rowHTML)
}

html.append("</tbody>")
return render()
}

func plainText() -> String {
var text = header.map(plainText) ?? ""

for row in rows {
if !text.isEmpty { text.append("\n") }
text.append(plainText(forRow: row))
}

return text
}
}

private extension Table {
typealias Row = [FormattedText]
typealias Cell = FormattedText

static let delimiters: Set<Character> = ["|", "\n"]
static let allowedHeaderCharacters: Set<Character> = ["-", ":"]

enum ColumnAlignment {
case none
case left
case center
case right

var attribute: String {
switch self {
case .none:
return ""
case .left:
return #" align="left""#
case .center:
return #" align="center""#
case .right:
return #" align="right""#
}
}
}

mutating func formHeaderAndColumnAlignmentsIfNeeded() {
guard rows.count > 1 else { return }
guard rows[0].count == rows[1].count else { return }

let textPredicate = Self.allowedHeaderCharacters.contains
var alignments = [ColumnAlignment]()

for cell in rows[1] {
let text = cell.plainText()

guard text.allSatisfy(textPredicate) else {
return
}

alignments.append(parseColumnAlignment(from: text))
}

header = rows[0]
columnAlignments = alignments
rows.removeSubrange(0...1)
}

func parseColumnAlignment(from text: String) -> ColumnAlignment {
switch (text.first, text.last) {
case (":", ":"):
return .center
case (":", _):
return .left
case (_, ":"):
return .right
default:
return .none
}
}

func html(forRow row: Row,
cellElementName: String,
urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
var html = "<tr>"

for index in 0..<columnCount {
let cell = index < row.count ? row[index] : nil
let contents = cell?.html(usingURLs: urls, modifiers: modifiers)

html.append(htmlForCell(
at: index,
contents: contents ?? "",
elementName: cellElementName
))
}

return html + "</tr>"
}

func htmlForCell(at index: Int, contents: String, elementName: String) -> String {
let alignment = index < columnAlignments.count
? columnAlignments[index]
: .none

let tags = (
opening: "<\(elementName)\(alignment.attribute)>",
closing: "</\(elementName)>"
)

return tags.opening + contents + tags.closing
}

func plainText(forRow row: Row) -> String {
var text = ""

for index in 0..<columnCount {
let cell = index < row.count ? row[index] : nil
if index > 0 { text.append(" | ") }
text.append(cell?.plainText() ?? "")
}

return text + " |"
}
}

private extension Reader {
mutating func readTableRow() throws -> Table.Row {
try readTableDelimiter()
var row = Table.Row()

while !didReachEnd {
let cell = FormattedText.read(
using: &self,
terminators: Table.delimiters
)

try readTableDelimiter()
row.append(cell)

if !didReachEnd, currentCharacter.isNewline {
advanceIndex()
break
}
}

return row
}

mutating func readTableDelimiter() throws {
try read("|")
discardWhitespaces()
}
}
Loading

0 comments on commit bd223a2

Please sign in to comment.