From d6ff2141dce00eeab84a78303d5a08f5d810772c Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 2 Oct 2021 10:28:36 -0400 Subject: [PATCH] GeminiRenderer: Add Markdown renderer --- GeminiRenderer/GeminiHTMLRenderer.swift | 2 +- GeminiRenderer/GeminiMarkdownRenderer.swift | 82 ++++++++++++++ .../GeminiMarkdownRendererTests.swift | 103 ++++++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 GeminiRenderer/GeminiMarkdownRenderer.swift create mode 100644 GeminiRendererTests/GeminiMarkdownRendererTests.swift diff --git a/GeminiRenderer/GeminiHTMLRenderer.swift b/GeminiRenderer/GeminiHTMLRenderer.swift index 9892179..b3b85ab 100644 --- a/GeminiRenderer/GeminiHTMLRenderer.swift +++ b/GeminiRenderer/GeminiHTMLRenderer.swift @@ -73,7 +73,7 @@ public class GeminiHTMLRenderer { } -fileprivate extension Document.Line { +extension Document.Line { var isListItem: Bool { switch self { case .unorderedListItem(_): diff --git a/GeminiRenderer/GeminiMarkdownRenderer.swift b/GeminiRenderer/GeminiMarkdownRenderer.swift new file mode 100644 index 0000000..90e1971 --- /dev/null +++ b/GeminiRenderer/GeminiMarkdownRenderer.swift @@ -0,0 +1,82 @@ +// +// GeminiMarkdownRenderer.swift +// GeminiRenderer +// +// Created by Shadowfacts on 10/1/21. +// + +import Foundation +import GeminiFormat +import HTMLEntities + +public class GeminiMarkdownRenderer { + + public init() { + } + + public func renderDocumentToMarkdown(_ doc: Document) -> String { + var str = "" + + var inPreformatting = false + var inList = false + + for line in doc.lines { + if inList && !line.isListItem { + str += "\n" + inList = false + } + + switch line { + case let .text(text): + if !text.trimmingCharacters(in: .whitespaces).isEmpty { + str += text.htmlEscape() + str += "\n\n" + } + + case let .link(url, text: maybeText): + let text = maybeText ?? url.absoluteString + // todo: do ] in the text need to be escaped? + str += "[\(text.htmlEscape())](\(url))" + str += "\n\n" + + case let .preformattedToggle(alt: alt): + inPreformatting = !inPreformatting + if inPreformatting { + str += "```" + if let alt = alt { + str += alt + } + str += "\n" + } else { + str += "```" + str += "\n\n" + } + + case let .preformattedText(text): + str += text + str += "\n" + + case let .heading(text, level: level): + str += String(repeating: "#", count: level.rawValue) + str += " " + str += text.htmlEscape() + str += "\n\n" + + case let .unorderedListItem(text): + if !inList { + inList = true + } + str += "* \(text.htmlEscape())" + str += "\n" + + case let .quote(text): + str += "> " + str += text.htmlEscape() + str += "\n\n" + } + } + + return str + } + +} diff --git a/GeminiRendererTests/GeminiMarkdownRendererTests.swift b/GeminiRendererTests/GeminiMarkdownRendererTests.swift new file mode 100644 index 0000000..a1f5104 --- /dev/null +++ b/GeminiRendererTests/GeminiMarkdownRendererTests.swift @@ -0,0 +1,103 @@ +// +// GeminiMarkdownRendererTests.swift +// GeminiRendererTests +// +// Created by Shadowfacts on 10/1/21. +// + +import XCTest +import GeminiFormat +@testable import GeminiRenderer + +class GeminiMarkdownRendererTests: XCTestCase { + + private var doc: Document! + + override func setUp() { + doc = Document(url: URL(string: "gemini://example.com/")!) + } + + func testEscapeToEntities() { + doc.lines = [.text("hello")] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "<b>hello</b>\n\n") + } + + func testParagraph() { + doc.lines = [.text("Hello, world!")] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "Hello, world!\n\n") + } + + func testLink() { + doc.lines = [.link(URL(string: "gemini://example.com/")!, text: "text")] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "[text](gemini://example.com/)\n\n") + + doc.lines = [.link(URL(string: "gemini://example.com/")!, text: nil)] + let noText = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(noText, "[gemini://example.com/](gemini://example.com/)\n\n") + } + + func testLinksAfterList() { + doc.lines = [ + .unorderedListItem("a"), + .unorderedListItem("b"), + .link(URL(string: "gemini://example.com")!, text: "one"), + .link(URL(string: "gemini://example.com")!, text: "two"), + ] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "* a\n* b\n\n[one](gemini://example.com)\n\n[two](gemini://example.com)\n\n") + } + + func testPreformatting() { + doc.lines = [ + .preformattedToggle(alt: nil), + .preformattedText("foo"), + .preformattedText("* bar"), + .preformattedToggle(alt: nil), + ] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "```\nfoo\n* bar\n```\n\n") + } + + func testHeading() { + doc.lines = [ + .heading("One", level: .h1), + .heading("Two", level: .h2), + ] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "# One\n\n## Two\n\n") + } + + func testUnorderedList() { + doc.lines = [ + .text("before"), + .unorderedListItem("a"), + .unorderedListItem("b"), + .unorderedListItem("c"), + .text("after"), + ] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "before\n\n* a\n* b\n* c\n\nafter\n\n") + } + + func testQuote() { + doc.lines = [ + .quote("quoted") + ] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "> quoted\n\n") + } + + func testSkipBlankLines() { + doc.lines = [ + .heading("Hello", level: .h1), + .text(""), + .text("World"), + ] + let markdown = GeminiMarkdownRenderer().renderDocumentToMarkdown(doc) + XCTAssertEqual(markdown, "# Hello\n\nWorld\n\n") + } + +}