diff --git a/README.md b/README.md index f8a6f68..143da04 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/Sources/Ink/API/MarkdownParser.swift b/Sources/Ink/API/MarkdownParser.swift index bbea736..5049654 100644 --- a/Sources/Ink/API/MarkdownParser.swift +++ b/Sources/Ink/API/MarkdownParser.swift @@ -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 } } diff --git a/Sources/Ink/API/Modifier.swift b/Sources/Ink/API/Modifier.swift index 24ecfa1..980ecb1 100644 --- a/Sources/Ink/API/Modifier.swift +++ b/Sources/Ink/API/Modifier.swift @@ -52,5 +52,6 @@ public extension Modifier { case links case lists case paragraphs + case tables } } diff --git a/Sources/Ink/Internal/FormattedText.swift b/Sources/Ink/Internal/FormattedText.swift index 0be2e39..a1d54f8 100644 --- a/Sources/Ink/Internal/FormattedText.swift +++ b/Sources/Ink/Internal/FormattedText.swift @@ -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) -> Self { + var parser = Parser(reader: reader, terminators: terminators) parser.parse() reader = parser.reader return parser.text @@ -79,15 +79,15 @@ private extension FormattedText { struct Parser { var reader: Reader - let terminator: Character? + let terminators: Set var text = FormattedText() var pendingTextRange: Range var activeStyles = Set() var activeStyleMarkers = [TextStyleMarker]() - init(reader: Reader, terminator: Character?) { + init(reader: Reader, terminators: Set) { self.reader = reader - self.terminator = terminator + self.terminators = terminators self.pendingTextRange = reader.currentIndex.. 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) } diff --git a/Sources/Ink/Internal/Link.swift b/Sources/Ink/Internal/Link.swift index 11f19a6..8770093 100644 --- a/Sources/Ink/Internal/Link.swift +++ b/Sources/Ink/Internal/Link.swift @@ -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() } diff --git a/Sources/Ink/Internal/Table.swift b/Sources/Ink/Internal/Table.swift new file mode 100644 index 0000000..ac98f89 --- /dev/null +++ b/Sources/Ink/Internal/Table.swift @@ -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 = { "\(html)
" } + + if let header = header { + let rowHTML = self.html( + forRow: header, + cellElementName: "th", + urls: urls, + modifiers: modifiers + ) + + html.append("\(rowHTML)") + } + + guard !rows.isEmpty else { + return render() + } + + html.append("") + + for row in rows { + let rowHTML = self.html( + forRow: row, + cellElementName: "td", + urls: urls, + modifiers: modifiers + ) + + html.append(rowHTML) + } + + html.append("") + 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 = ["|", "\n"] + static let allowedHeaderCharacters: Set = ["-", ":"] + + 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 = "" + + for index in 0.." + } + + 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: "" + ) + + return tags.opening + contents + tags.closing + } + + func plainText(forRow row: Row) -> String { + var text = "" + + for index in 0.. 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() + } +} diff --git a/Tests/InkTests/TableTests.swift b/Tests/InkTests/TableTests.swift new file mode 100644 index 0000000..bfe06e6 --- /dev/null +++ b/Tests/InkTests/TableTests.swift @@ -0,0 +1,225 @@ +/** + * Ink + * Copyright (c) John Sundell 2020 + * MIT license, see LICENSE file for details + */ + +import XCTest +import Ink + +final class TableTests: XCTestCase { + func testTableWithoutHeader() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | + | CellA | CellB | + """) + + XCTAssertEqual(html, """ + \ + \ + \ +
HeaderAHeaderB
CellACellB
+ """) + } + + func testTableWithHeader() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | HeaderC | + | ------- | ------- | ------- | + | CellA1 | CellB1 | CellC1 | + | CellA2 | CellB2 | CellC2 | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ + \ + \ +
HeaderAHeaderBHeaderC
CellA1CellB1CellC1
CellA2CellB2CellC2
+ """) + } + + func testTableWithUnalignedColumns() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | HeaderC | + | ------------------------------ | ----------- | ------------ | + | CellA1 | CellB1 | CellC1 | + | CellA2 | CellB2 | CellC2 | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ + \ + \ +
HeaderAHeaderBHeaderC
CellA1CellB1CellC1
CellA2CellB2CellC2
+ """) + } + + func testTableWithOnlyHeader() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | HeaderC | + | ----------| ----------| ------- | + """) + + XCTAssertEqual(html, """ + \ + \ +
HeaderAHeaderBHeaderC
+ """) + } + + func testIncompleteTable() { + let html = MarkdownParser().html(from: """ + | one | two | + | three | + | four | five | six + """) + + XCTAssertEqual(html, "

| one | two | | three | | four | five | six

") + } + + func testInvalidTable() { + let html = MarkdownParser().html(from: """ + |123 Not a table + """) + + XCTAssertEqual(html, "

|123 Not a table

") + } + + func testTableBetweenParagraphs() { + let html = MarkdownParser().html(from: """ + A paragraph. + + | A | B | + | C | D | + + Another paragraph. + """) + + XCTAssertEqual(html, """ +

A paragraph.

\ + \ + \ +
AB
CD
\ +

Another paragraph.

+ """) + } + + func testTableWithUnevenColumns() { + let html = MarkdownParser().html(from: """ + | one | two | + | three | four | five | + + | one | two | + | three | + """) + + XCTAssertEqual(html, """ + \ + \ + \ +
onetwo
threefourfive
\ + \ + \ + \ +
onetwo
three
+ """) + } + + func testTableWithInternalMarkdown() { + let html = MarkdownParser().html(from: """ + | Table | Header | [Link](/uri) | + | ------ | ---------- | ------------ | + | Some | *emphasis* | and | + | `code` | in | table | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ + \ + \ + \ + \ +
TableHeaderLink
Someemphasisand
codeintable
+ """) + } + + func testTableWithAlignment() { + let html = MarkdownParser().html(from: """ + | Left | Center | Right | + | :- | :-: | -:| + | One | Two | Three | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ + \ + \ + \ +
LeftCenterRight
OneTwoThree
+ """) + } + + func testMissingPipeEndsTable() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | + | ------- | ------- | + | CellA | CellB | + > Quote + """) + + XCTAssertEqual(html, """ + \ + \ + \ +
HeaderAHeaderB
CellACellB
\ +

Quote

+ """) + } + + func testHeaderNotParsedForColumnCountMismatch() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | + | ------- | + | CellA | CellB | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ +
HeaderAHeaderB
-------
CellACellB
+ """) + } +} + +extension TableTests { + static var allTests: Linux.TestList { + return [ + ("testTableWithoutHeader", testTableWithoutHeader), + ("testTableWithHeader", testTableWithHeader), + ("testTableWithUnalignedColumns", testTableWithUnalignedColumns), + ("testTableWithOnlyHeader", testTableWithOnlyHeader), + ("testIncompleteTable", testIncompleteTable), + ("testInvalidTable", testInvalidTable), + ("testTableBetweenParagraphs", testTableBetweenParagraphs), + ("testTableWithUnevenColumns", testTableWithUnevenColumns), + ("testTableWithInternalMarkdown", testTableWithInternalMarkdown), + ("testTableWithAlignment", testTableWithAlignment), + ("testMissingPipeEndsTable", testMissingPipeEndsTable), + ("testHeaderNotParsedForColumnCountMismatch", testHeaderNotParsedForColumnCountMismatch), + ] + } +} diff --git a/Tests/InkTests/XCTestManifests.swift b/Tests/InkTests/XCTestManifests.swift index 81a1207..0244f80 100644 --- a/Tests/InkTests/XCTestManifests.swift +++ b/Tests/InkTests/XCTestManifests.swift @@ -17,6 +17,7 @@ public func allTests() -> [Linux.TestCase] { Linux.makeTestCase(using: ListTests.allTests), Linux.makeTestCase(using: MarkdownTests.allTests), Linux.makeTestCase(using: ModifierTests.allTests), + Linux.makeTestCase(using: TableTests.allTests), Linux.makeTestCase(using: TextFormattingTests.allTests) ] }