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

Support titles for links and images #42

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
4 changes: 2 additions & 2 deletions Sources/Ink/API/MarkdownParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public struct MarkdownParser {
public func parse(_ markdown: String) -> Markdown {
var reader = Reader(string: markdown)
var fragments = [ParsedFragment]()
var urlsByName = [String : URL]()
var urlsByName = [String : URLDeclaration]()
var titleHeading: Heading?
var metadata: Metadata?

Expand All @@ -60,7 +60,7 @@ public struct MarkdownParser {

guard reader.currentCharacter != "[" else {
let declaration = try URLDeclaration.readOrRewind(using: &reader)
urlsByName[declaration.name] = declaration.url
urlsByName[declaration.name] = declaration
continue
}

Expand Down
22 changes: 22 additions & 0 deletions Sources/Ink/Internal/Character+Classification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,31 @@ internal extension Character {
var isSameLineWhitespace: Bool {
isWhitespace && !isNewline
}

var isLegalInURL: Bool {
self != ")" && self != " " && self != "\n"
}

var isSameLineNonWhitespace: Bool {
!isWhitespace && !isNewline
}
}

internal extension Set where Element == Character {
static let boldItalicStyleMarkers: Self = ["*", "_"]
static let allStyleMarkers: Self = boldItalicStyleMarkers.union(["~"])
}

internal enum TitleDelimeter: Character {
case doubleQuote = "\""
case singleQuote = "'"
case parenthetical = "("
var closing: Character {
switch self {
case .parenthetical:
return ")"
default:
return self.rawValue
}
}
}
10 changes: 9 additions & 1 deletion Sources/Ink/Internal/Image.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,21 @@ internal struct Image: Fragment {
func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
let url = link.target.url(from: urls)
let refTitle = link.target.title(from: urls)

let finalTitle = refTitle ?? link.title
var titleAttribute: String = ""
if let finalTitle = finalTitle {
titleAttribute = " title=\"\(finalTitle)\""
}

var alt = link.text.html(usingURLs: urls, modifiers: modifiers)

if !alt.isEmpty {
alt = " alt=\"\(alt)\""
}

return "<img src=\"\(url)\"\(alt)/>"
return "<img src=\"\(url)\"\(alt)\(titleAttribute)/>"
}

func plainText() -> String {
Expand Down
37 changes: 31 additions & 6 deletions Sources/Ink/Internal/Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ internal struct Link: Fragment {

var target: Target
var text: FormattedText
var title: Substring?

static func read(using reader: inout Reader) throws -> Link {
try reader.read("[")
Expand All @@ -19,20 +20,35 @@ internal struct Link: Fragment {

if reader.currentCharacter == "(" {
reader.advanceIndex()
let url = try reader.read(until: ")")
return Link(target: .url(url), text: text)
let url = try? reader.readCharacters(matching: \.isLegalInURL)

guard !reader.didReachEnd else { throw Reader.Error() }
var titleText: Substring? = nil
if reader.currentCharacter.isSameLineWhitespace {
try reader.readWhitespaces()
try reader.read("\"")
titleText = try reader.read(until: "\"")
}
try reader.read(")")
return Link(target: .url(url ?? ""), text: text, title: titleText)
} else {
try reader.read("[")
let reference = try reader.read(until: "]")
return Link(target: .reference(reference), text: text)
return Link(target: .reference(reference), text: text, title: nil)
}
}

func html(usingURLs urls: NamedURLCollection,
modifiers: ModifierCollection) -> String {
let url = target.url(from: urls)
let title = text.html(usingURLs: urls, modifiers: modifiers)
return "<a href=\"\(url)\">\(title)</a>"
let refTitle = target.title(from: urls)
let linkText = text.html(usingURLs: urls, modifiers: modifiers)
let finalTitle = refTitle ?? title
var titleAttribute: String = ""
if let finalTitle = finalTitle {
titleAttribute = " title=\"\(finalTitle)\""
}
return "<a href=\"\(url)\"\(titleAttribute)>\(linkText)</a>"
}

func plainText() -> String {
Expand All @@ -53,7 +69,16 @@ extension Link.Target {
case .url(let url):
return url
case .reference(let name):
return urls.url(named: name) ?? name
return urls.url(named: name)?.url ?? name
}
}

func title(from urls: NamedURLCollection) -> Substring? {
switch self {
case .url:
return nil
case .reference(let name):
return urls.url(named: name)?.title
}
}
}
6 changes: 3 additions & 3 deletions Sources/Ink/Internal/NamedURLCollection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
*/

internal struct NamedURLCollection {
private let urlsByName: [String : URL]
private let urlsByName: [String : URLDeclaration]

init(urlsByName: [String : URL]) {
init(urlsByName: [String : URLDeclaration]) {
self.urlsByName = urlsByName
}

func url(named name: Substring) -> URL? {
func url(named name: Substring) -> URLDeclaration? {
urlsByName[name.lowercased()]
}
}
25 changes: 23 additions & 2 deletions Sources/Ink/Internal/URLDeclaration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,35 @@
internal struct URLDeclaration: Readable {
var name: String
var url: URL
var title: Substring?

static func read(using reader: inout Reader) throws -> Self {
try reader.read("[")
let name = try reader.read(until: "]")
try reader.read(":")
try reader.readWhitespaces()
let url = reader.readUntilEndOfLine()

return URLDeclaration(name: name.lowercased(), url: url)
var titleText: Substring? = nil
let url = try reader.readCharacters(matching: \.isSameLineNonWhitespace)

if !reader.didReachEnd {
if reader.currentCharacter.isNewline {
reader.advanceIndex()
}
if reader.currentCharacter.isSameLineWhitespace {
try reader.readWhitespaces()
}
if let delimeter = TitleDelimeter(rawValue: reader.currentCharacter) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if let delimeter = TitleDelimeter(rawValue: reader.currentCharacter) {
if let delimeter = TitleDelimeter(rawValue: reader.currentCharacter) {
let index = reader.currentIndex

We capture the index at the start of the title parsing, in case there is an otherwise valid title followed by non-whitespace. If that happens, we rewind the reader to the beginning of what would have been the title, and set titleText = nil.

let index = reader.currentIndex
reader.advanceIndex()
titleText = try reader.read(until: delimeter.closing)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
titleText = try reader.read(until: delimeter.closing)
titleText = try reader.read(until: delimeter.closing)
reader.discardWhitespaces()
if !reader.didReachEnd && !reader.currentCharacter.isWhitespace {
reader.moveToIndex(index)
titleText = nil
}

This helps fix the new failure of CommonMark test 179:
"No further non-whitespace characters may occur on the line." (spec & example)

reader.discardWhitespaces()
if !reader.didReachEnd && !reader.currentCharacter.isWhitespace {
reader.moveToIndex(index)
titleText = nil
}
}
}
return URLDeclaration(name: name.lowercased(), url: url, title: titleText)
}
}
27 changes: 27 additions & 0 deletions Tests/InkTests/ImageTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ final class ImageTests: XCTestCase {
XCTAssertEqual(html, #"<img src="url" alt="Alt text"/>"#)
}

func testImageWithURLAndAltTextAndTitle() {
let html = MarkdownParser().html(from: "![Alt text](url \"Swift by Sundell\")")
XCTAssertEqual(html, #"<img src="url" alt="Alt text" title="Swift by Sundell"/>"#)
}

func testImageWithReferenceAndAltText() {
let html = MarkdownParser().html(from: """
![Alt text][url]
Expand All @@ -36,6 +41,25 @@ final class ImageTests: XCTestCase {
XCTAssertEqual(html, #"<img src="swiftbysundell.com" alt="Alt text"/>"#)
}

func testImageWithReferenceAndAltTextAndTitle() {
let html = MarkdownParser().html(from: """
![Alt text][url]
[url]: swiftbysundell.com 'Swift by Sundell'
""")

XCTAssertEqual(html, #"<img src="swiftbysundell.com" alt="Alt text" title="Swift by Sundell"/>"#)
}

func testImageWithReferenceAndAltTextAndNewlineTitle() {
let html = MarkdownParser().html(from: """
![Alt text][url]
[url]: swiftbysundell.com
(Swift by Sundell)
""")

XCTAssertEqual(html, #"<img src="swiftbysundell.com" alt="Alt text" title="Swift by Sundell"/>"#)
}

func testImageWithinParagraph() {
let html = MarkdownParser().html(from: "Text ![](url) text")
XCTAssertEqual(html, #"<p>Text <img src="url"/> text</p>"#)
Expand All @@ -48,7 +72,10 @@ extension ImageTests {
("testImageWithURL", testImageWithURL),
("testImageWithReference", testImageWithReference),
("testImageWithURLAndAltText", testImageWithURLAndAltText),
("testImageWithURLAndAltTextAndTitle", testImageWithURLAndAltTextAndTitle),
("testImageWithReferenceAndAltText", testImageWithReferenceAndAltText),
("testImageWithReferenceAndAltTextAndTitle", testImageWithReferenceAndAltTextAndTitle),
("testImageWithReferenceAndAltTextAndNewlineTitle", testImageWithReferenceAndAltTextAndNewlineTitle),
("testImageWithinParagraph", testImageWithinParagraph)
]
}
Expand Down
93 changes: 91 additions & 2 deletions Tests/InkTests/LinkTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ final class LinkTests: XCTestCase {
XCTAssertEqual(html, #"<p><a href="url">Title</a></p>"#)
}

func testLinkWithURLAndTitle() {
let html = MarkdownParser().html(from: "[Title](url \"Swift by Sundell\")")
XCTAssertEqual(html, #"<p><a href="url" title="Swift by Sundell">Title</a></p>"#)
}

func testLinkWithoutURLAndTitle() {
let html = MarkdownParser().html(from: "[link]()")

XCTAssertEqual(html, #"<p><a href="">link</a></p>"#)
}

func testLinkWithReference() {
let html = MarkdownParser().html(from: """
[Title][url]
Expand All @@ -23,6 +34,47 @@ final class LinkTests: XCTestCase {
XCTAssertEqual(html, #"<p><a href="swiftbysundell.com">Title</a></p>"#)
}

func testLinkWithReferenceAndDoubleQuoteTitle() {
let html = MarkdownParser().html(from: """
[Title][url]

[url]: swiftbysundell.com "Powered by Publish"
""")

XCTAssertEqual(html, #"<p><a href="swiftbysundell.com" title="Powered by Publish">Title</a></p>"#)
}

func testLinkWithReferenceAndSingleQuoteTitle() {
let html = MarkdownParser().html(from: """
[Title][url]

[url]: swiftbysundell.com 'Powered by Publish'
""")

XCTAssertEqual(html, #"<p><a href="swiftbysundell.com" title="Powered by Publish">Title</a></p>"#)
}

func testLinkWithReferenceAndParentheticalTitle() {
let html = MarkdownParser().html(from: """
[Title][url]

[url]: swiftbysundell.com (Powered by Publish)
""")

XCTAssertEqual(html, #"<p><a href="swiftbysundell.com" title="Powered by Publish">Title</a></p>"#)
}

func testLinkWithReferenceAndNewlineTitle() {
let html = MarkdownParser().html(from: """
[Title][url]

[url]: swiftbysundell.com
'Powered by Publish'
""")

XCTAssertEqual(html, #"<p><a href="swiftbysundell.com" title="Powered by Publish">Title</a></p>"#)
}

func testCaseMismatchedLinkWithReference() {
let html = MarkdownParser().html(from: """
[Title][Foo]
Expand Down Expand Up @@ -64,25 +116,62 @@ final class LinkTests: XCTestCase {
let html = MarkdownParser().html(from: "[Hello]")
XCTAssertEqual(html, "<p>[Hello]</p>")
}

func testLinkWithEscapedSquareBrackets() {
let html = MarkdownParser().html(from: "[\\[Hello\\]](hello)")
XCTAssertEqual(html, #"<p><a href="hello">[Hello]</a></p>"#)
}

func testLinkDestinationCannotIncludeLinkBreaks() {
let html = MarkdownParser().html(from: """
[link](foo
bar)
""")

XCTAssertEqual(html, #"<p>[link](foo bar)</p>"#)
}

func testLinkReferenceTitleMustEndLine() {
let html = MarkdownParser().html(from: """
[foo]: /url
"title" ok
""")

XCTAssertEqual(html, #"<p>"title" ok</p>"#)
}

func testInlineLinkHasPrecedenceOverReferenceLink() {
let html = MarkdownParser().html(from: """
[foo]()

[foo]: /url1
""")

XCTAssertEqual(html, #"<p><a href="">foo</a></p>"#)
}
}

extension LinkTests {
static var allTests: Linux.TestList<LinkTests> {
return [
("testLinkWithURL", testLinkWithURL),
("testLinkWithURLAndTitle", testLinkWithURLAndTitle),
("testLinkWithoutURLAndTitle", testLinkWithoutURLAndTitle),
("testLinkWithReference", testLinkWithReference),
("testLinkWithReferenceAndDoubleQuoteTitle", testLinkWithReferenceAndDoubleQuoteTitle),
("testLinkWithReferenceAndSingleQuoteTitle", testLinkWithReferenceAndSingleQuoteTitle),
("testLinkWithReferenceAndParentheticalTitle", testLinkWithReferenceAndParentheticalTitle),
("testLinkWithReferenceAndNewlineTitle", testLinkWithReferenceAndNewlineTitle),
("testCaseMismatchedLinkWithReference", testCaseMismatchedLinkWithReference),
("testNumericLinkWithReference", testNumericLinkWithReference),
("testBoldLinkWithInternalMarkers", testBoldLinkWithInternalMarkers),
("testBoldLinkWithExternalMarkers", testBoldLinkWithExternalMarkers),
("testLinkWithUnderscores", testLinkWithUnderscores),
("testUnterminatedLink", testUnterminatedLink),
("testLinkWithEscapedSquareBrackets", testLinkWithEscapedSquareBrackets)
("testLinkWithEscapedSquareBrackets", testLinkWithEscapedSquareBrackets),
("testLinkDestinationCannotIncludeLinkBreaks", testLinkDestinationCannotIncludeLinkBreaks),
("testLinkReferenceTitleMustEndLine", testLinkReferenceTitleMustEndLine),
("testInlineLinkHasPrecedenceOverReferenceLink", testInlineLinkHasPrecedenceOverReferenceLink)
]
}
}