Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for most common nested elements in block quotes #49

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 54 additions & 14 deletions Sources/Ink/Internal/Blockquote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,37 +7,77 @@
internal struct Blockquote: Fragment {
var modifierTarget: Modifier.Target { .blockquotes }

private var text: FormattedText
private var items = [Fragment]()

static func read(using reader: inout Reader) throws -> Blockquote {
try read(using: &reader, ignorePrefix: nil)
}

static func read(using reader: inout Reader, ignorePrefix: String?) throws -> Blockquote {
var blockquote = Blockquote()
try reader.read(">")
try reader.readWhitespaces()
reader.rewindUntilBeginningOfLine()
while !reader.didReachEnd {

var text = FormattedText.readLine(using: &reader)
if let ignorePrefix = ignorePrefix {
if let lookAhead = reader.lookAheadAtCharacters(ignorePrefix.count) {
if lookAhead == ignorePrefix {
for _ in 0..<ignorePrefix.count {
reader.advanceIndex()
}
reader.discardWhitespaces()
}
}
}

while !reader.didReachEnd {
switch reader.currentCharacter {
case \.isNewline:
return Blockquote(text: text)
let firstChar = reader.currentCharacter
switch firstChar {
case ">":
reader.advanceIndex() // Move past the angle bracket.
reader.discardWhitespaces()
let nextChar = reader.currentCharacter
switch nextChar {
case "#":
let heading = try Heading.read(using: &reader)
blockquote.items.append(heading)
if reader.currentCharacter == "\n" {
reader.advanceIndex()
}
case "-", "*", "+", \.isNumber:
let list = try List.read(using: &reader, ignorePrefix: ">")
blockquote.items.append(list)
case ">":
let nestedBlockquote = try Blockquote.read(using: &reader, ignorePrefix: ">")
blockquote.items.append(nestedBlockquote)
case \.isNewline:
reader.advanceIndex()
default:
blockquote.items.append(
Paragraph.read(using: &reader, ignorePrefix: ">"))
}
case \.isNewline:
reader.advanceIndex()
return blockquote
default:
break
}

text.append(FormattedText.readLine(using: &reader))
}

return Blockquote(text: text)
return blockquote
}

func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
let body = text.html(usingURLs: urls, modifiers: modifiers)
return "<blockquote><p>\(body)</p></blockquote>"
// First get the HTML representation of the paragraphs.
let body = items.reduce(into: "") { html, item in
html.append(item.html(usingURLs: urls, modifiers: modifiers))
}
// Now wrap everything in a blockquote tag.
return "<blockquote>\(body)</blockquote>"
}

func plainText() -> String {
text.plainText()
return items.reduce(into: "") { string, item in
string.append(item.plainText())
}
}
}
25 changes: 22 additions & 3 deletions Sources/Ink/Internal/FormattedText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ internal struct FormattedText: Readable, HTMLConvertible, PlainTextConvertible {
}

static func read(using reader: inout Reader,
terminator: Character?) -> Self {
var parser = Parser(reader: reader, terminator: terminator)
terminator: Character?,
ignorePrefix: String? = nil) -> Self {
var parser = Parser(
reader: reader, terminator: terminator, ignorePrefix: ignorePrefix)
parser.parse()
reader = parser.reader
return parser.text
Expand Down Expand Up @@ -80,14 +82,16 @@ private extension FormattedText {
struct Parser {
var reader: Reader
let terminator: Character?
let ignorePrefix: String?
var text = FormattedText()
var pendingTextRange: Range<String.Index>
var activeStyles = Set<TextStyle>()
var activeStyleMarkers = [TextStyleMarker]()

init(reader: Reader, terminator: Character?) {
init(reader: Reader, terminator: Character?, ignorePrefix: String?) {
self.reader = reader
self.terminator = terminator
self.ignorePrefix = ignorePrefix
self.pendingTextRange = reader.currentIndex..<reader.endIndex
}

Expand All @@ -105,6 +109,21 @@ private extension FormattedText {
if reader.currentCharacter.isNewline {
addPendingTextIfNeeded()

// Look ahead to see if the next line has a prefix to ignore.
if let ignorePrefix = ignorePrefix {
if let nextPrefix = reader.lookAheadAtCharacters(
ignorePrefix.count + 1
) {
// The look ahead includes the current newline character,
// so drop it when comparing.
if nextPrefix.dropFirst() == ignorePrefix {
for _ in 0..<ignorePrefix.count {
skipCharacter()
}
}
}
}

guard let nextCharacter = reader.nextCharacter else {
break
}
Expand Down
19 changes: 18 additions & 1 deletion Sources/Ink/Internal/List.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@ internal struct List: Fragment {
try read(using: &reader, indentationLength: 0)
}

static func read(using reader: inout Reader, ignorePrefix: String? = nil) throws -> List {
try read(using: &reader, indentationLength: 0, ignorePrefix: ignorePrefix)
}

private static func read(using reader: inout Reader,
indentationLength: Int) throws -> List {
indentationLength: Int,
ignorePrefix: String? = nil) throws -> List {
let startIndex = reader.currentIndex
let isOrdered = reader.currentCharacter.isNumber

Expand Down Expand Up @@ -44,6 +49,18 @@ internal struct List: Fragment {
}

while !reader.didReachEnd {

if let ignorePrefix = ignorePrefix {
if let lookAhead = reader.lookAheadAtCharacters(ignorePrefix.count) {
if lookAhead == ignorePrefix {
for _ in 0..<ignorePrefix.count {
reader.advanceIndex()
}
reader.discardWhitespaces()
}
}
}

switch reader.currentCharacter {
case \.isNewline:
return list
Expand Down
6 changes: 6 additions & 0 deletions Sources/Ink/Internal/Paragraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ internal struct Paragraph: Fragment {
return Paragraph(text: .read(using: &reader))
}

static func read(using reader: inout Reader, ignorePrefix: String) -> Paragraph {
return Paragraph(text: .read(using: &reader,
terminator: nil,
ignorePrefix: ignorePrefix))
}

func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
let body = text.html(usingURLs: urls, modifiers: modifiers)
Expand Down
29 changes: 29 additions & 0 deletions Sources/Ink/Internal/Reader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ extension Reader {
struct Error: Swift.Error {}

var didReachEnd: Bool { currentIndex == endIndex }
var didReachBegining: Bool { currentIndex == startIndex }
var previousCharacter: Character? { lookBehindAtPreviousCharacter() }
var currentCharacter: Character { string[currentIndex] }
var nextCharacter: Character? { lookAheadAtNextCharacter() }
var endIndex: String.Index { string.endIndex }
var startIndex: String.Index { string.startIndex }

func characters(in range: Range<String.Index>) -> Substring {
return string[range]
Expand Down Expand Up @@ -159,9 +161,36 @@ extension Reader {
currentIndex = string.index(before: currentIndex)
}

mutating func rewindUntilBeginningOfLine() {
guard !currentCharacter.isNewline else { return }
while !didReachBegining {
if let prevChar = previousCharacter {
if !prevChar.isNewline {
rewindIndex()
} else {
return
}
} else {
return
}
}
}

mutating func moveToIndex(_ index: String.Index) {
currentIndex = index
}

func lookAheadAtCharacters(_ count: Int) -> Substring? {
// Returns a substring of the next n characters without advancing the current
// index.
guard !didReachEnd else { return nil }
if let endIndex = string.index(currentIndex,
offsetBy: count,
limitedBy: string.endIndex) {
return string[currentIndex..<endIndex]
}
return nil
}
}

private extension Reader {
Expand Down
94 changes: 93 additions & 1 deletion Tests/InkTests/TextFormattingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
* MIT license, see LICENSE file for details
*/

import XCTest
import Ink
import XCTest

final class TextFormattingTests: XCTestCase {
func testParagraph() {
Expand Down Expand Up @@ -118,7 +118,46 @@ final class TextFormattingTests: XCTestCase {
XCTAssertEqual(html, "<blockquote><p>Hello, world!</p></blockquote>")
}

func testNestedBlockquote() {
let html = MarkdownParser().html(from: """
> > Foo
""")
XCTAssertEqual(html, "<blockquote><blockquote><p>Foo</p></blockquote></blockquote>")
}

func testUnorderedListInBlockquote() {
let html = MarkdownParser().html(from: """
> * First Item
> * Second Item
""")
XCTAssertEqual(html, "<blockquote><ul><li>First Item</li><li>Second Item</li></ul></blockquote>")
}

func testH1InBlockquote() {
// https://spec.commonmark.org/0.29/#block-quotes Example 198
let html = MarkdownParser().html(from: """
> # Foo
> bar
> baz
""")
XCTAssertEqual(html, "<blockquote><h1>Foo</h1><p>bar baz</p></blockquote>")
}

func testBlankLineInBlockquoteSeparates() {
// https://spec.commonmark.org/0.29/#block-quotes Example 212
// According to the CommonMark spec, this should produce two blockquote elements.
let html = MarkdownParser().html(from: """
> foo

> bar
""")
XCTAssertEqual(html, "<blockquote><p>foo</p></blockquote><blockquote><p>bar</p></blockquote>")
}

func testMultiLineBlockquote() {
// https://spec.commonmark.org/0.29/#block-quotes Example 213
// According to the CommonMark spec, this should produce one blockquote element
// with one paragraph.
let html = MarkdownParser().html(from: """
> One
> Two
Expand All @@ -128,6 +167,53 @@ final class TextFormattingTests: XCTestCase {
XCTAssertEqual(html, "<blockquote><p>One Two Three</p></blockquote>")
}

func testMultiParagraphBlockquote() {
// https://spec.commonmark.org/0.29/#block-quotes Example 214
// According to the CommonMark spec, this should produce one blockquote element
// containing two paragraphs.
let html = MarkdownParser().html(
from: """
> foo
>
> bar
""")

XCTAssertEqual(
html,
"<blockquote><p>foo</p><p>bar</p></blockquote>"
)
}

func testMultiLineMultiParagraphBlockquote() {
// Related to Example 214 above, but this test ensures that multi-line paragraphs
// are preserved. Text borrowed from the swift.org homepage.
let html = MarkdownParser().html(
from: """
> Welcome to the Swift community. Together we are working to build a
> programming language to empower everyone to turn their ideas into apps
> on any platform.
>
> Announced in 2014, the Swift programming language has quickly become
> one of the fastest growing languages in history. Swift makes it easy to
> write software that is incredibly fast and safe by design. Our goals
> for Swift are ambitious: we want to make programming simple things
> easy, and difficult things possible.
""")

XCTAssertEqual(
html, """
<blockquote><p>Welcome to the Swift community. Together we are working \
to build a programming language to empower everyone to turn their ideas \
into apps on any platform.</p><p>Announced in 2014, the Swift \
programming language has quickly become one of the fastest growing \
languages in history. Swift makes it easy to write software that is \
incredibly fast and safe by design. Our goals for Swift are ambitious: \
we want to make programming simple things easy, and difficult things \
possible.</p></blockquote>
"""
)
}

func testEscapingSymbolsWithBackslash() {
let html = MarkdownParser().html(from: """
\\# Not a title
Expand Down Expand Up @@ -176,6 +262,12 @@ extension TextFormattingTests {
("testEncodingSpecialCharacters", testEncodingSpecialCharacters),
("testSingleLineBlockquote", testSingleLineBlockquote),
("testMultiLineBlockquote", testMultiLineBlockquote),
("testNestedBlockquote", testNestedBlockquote),
("testH1InBlockquote", testH1InBlockquote),
("testUnorderedListInBlockquote", testUnorderedListInBlockquote),
("testBlankLineInBlockquoteSeparates", testBlankLineInBlockquoteSeparates),
("testMultiParagraphBlockquote", testMultiParagraphBlockquote),
("testMultiLineMultiParagraphBlockquote", testMultiLineMultiParagraphBlockquote),
("testEscapingSymbolsWithBackslash", testEscapingSymbolsWithBackslash),
("testDoubleSpacedHardLinebreak", testDoubleSpacedHardLinebreak),
("testEscapedHardLinebreak", testEscapedHardLinebreak)
Expand Down