diff --git a/.prettierrc.yml b/.prettierrc.yml new file mode 100644 index 0000000..1deb191 --- /dev/null +++ b/.prettierrc.yml @@ -0,0 +1 @@ +proseWrap: always diff --git a/README.md b/README.md index 36a9e16..a82b1be 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 🏘️ Hoods -A collection of my Swift building blocks that are using few well known dependencies, such as [The Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture), as opposed to Blocks, my collection of dependency-free Swift code. +A collection of my Swift building blocks that are using few well known +dependencies, such as [The Composable Architecture][2], as opposed to +[Blocks][1], my collection of dependency-free Swift code. This repository contains: @@ -42,3 +44,6 @@ targets: [ ## License [MIT](https://choosealicense.com/licenses/mit/) + +[1]: https://github.com/dirtyhenry/swift-blocks +[2]: https://github.com/pointfreeco/swift-composable-architecture diff --git a/Sources/Hoods/CMS/FrontMatterCMark.swift b/Sources/Hoods/CMS/FrontMatterCMark.swift index cd03e48..0c234e5 100644 --- a/Sources/Hoods/CMS/FrontMatterCMark.swift +++ b/Sources/Hoods/CMS/FrontMatterCMark.swift @@ -2,71 +2,141 @@ import Blocks import Foundation import Yams -public struct FrontMatterCMark { - public let frontMatter: [String: Any] +/// Represents a combination of front matter (metadata) and CommonMark text. +/// +/// To learn more about front matters, please read the post I wrote about it on my technical blog: +/// [*My Definitive Front Matter*][1]. +/// +/// CommonMark is defined as: “a strongly defined, highly compatible specification of Markdown” +/// +/// You can read more about it on [commonmark.org][2]. +/// +/// [1]: https://bootstragram.com/blog/my-definitive-front-matter/ +/// [2]: https://commonmark.org/ +public struct FrontMatterCMark { + /// A front matter of a generic codable type. + public let frontMatter: FrontMatter? + + /// The CommonMark text associated with the front matter. public let cmark: String - public var hasFrontMatter: Bool { - !frontMatter.isEmpty - } - - public init(frontMatter: [String: Any] = [:], cmark: String = "") { + /// Initializes a new `FrontMatterCMark` instance. + /// + /// - Parameters: + /// - frontMatter: The decoded front matter of type `FrontMatter`. + /// - cmark: The CommonMark text associated with the front matter. + public init(frontMatter: FrontMatter?, cmark: String) { self.frontMatter = frontMatter self.cmark = cmark } } -public class FrontMatterCMarkParser { - let string: String - - static let frontMatterDelimiter = "---" +enum FrontMatterCMarkUtils { + public static let frontMatterDelimiter = "---" +} - public init(data: Data) throws { - guard let string = String(data: data, encoding: .utf8) else { - throw SimpleMessageError(message: "Cannot convert data to UTF8.") +/// A decoder for extracting front matter and CommonMark text from a given data. +public class FrontMatterCMarkDecoder { + /// Initializes a new `FrontMatterCMarkDecoder` instance. + public init() {} + + /// Decodes front matter and CommonMark text from the provided data. + /// + /// - Parameters: + /// - type: The type to decode the front matter into. + /// - data: The data containing front matter and CommonMark text. + /// - Returns: A `FrontMatterCMark` instance with the decoded front matter and CommonMark text. + /// - Throws: A decoding error if the front matter cannot be decoded. + public func decode( + _: FrontMatter.Type, + from data: Data + ) throws -> FrontMatterCMark where FrontMatter: Decodable { + guard let dataAsString = String(data: data, encoding: .utf8) else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Could not convert data to UTF8 string.")) } - self.string = string - } - - public func parse() throws -> FrontMatterCMark { - let lines = string.components(separatedBy: .newlines) + let lines = dataAsString.components(separatedBy: .newlines) - let nonEmptyLines = lines.filter { $0.trimmingCharacters(in: .whitespacesAndNewlines).count > 0 } - guard nonEmptyLines.count > 0 else { - return FrontMatterCMark() + let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard !nonEmptyLines.isEmpty else { + return FrontMatterCMark(frontMatter: nil, cmark: "") } if let frontMatterIndex = extractFrontMatter(lines: lines) { let frontMatterString = lines[1 ..< frontMatterIndex].joined(separator: "\n") let cmark = lines[(frontMatterIndex + 1)...].joined(separator: "\n") - let frontMatter = try Yams.load(yaml: frontMatterString) as! [String: Any] + let decoder = YAMLDecoder() + let frontMatter = try decoder.decode(FrontMatter.self, from: frontMatterString) + return FrontMatterCMark( frontMatter: frontMatter, cmark: cmark.trimmingCharacters(in: .whitespacesAndNewlines) ) } else { - return FrontMatterCMark(cmark: string.trimmingCharacters(in: .whitespacesAndNewlines)) + return FrontMatterCMark( + frontMatter: nil, + cmark: dataAsString.trimmingCharacters(in: .whitespacesAndNewlines) + ) } } - func extractFrontMatter(lines: [String]) -> Array.Index? { + private func extractFrontMatter(lines: [String]) -> Array.Index? { if lines.first!.isFrontMatterDelimiter() { return lines.dropFirst().firstIndex { $0.isFrontMatterDelimiter() } } return nil } +} + +/// A encoder for encoding `FrontMatterCMark` instances. +public class FrontMatterCMarkEncoder { + /// Initializes a new `FrontMatterCMarkEncoder` instance. + public init() {} + + /// Encodes the given `FrontMatterCMark` instance into data. + /// + /// - Parameter value: The `FrontMatterCMark` instance to encode. + /// - Returns: The encoded data. + /// - Throws: An encoding error if the front matter cannot be encoded. + public func encode( + _ value: FrontMatterCMark + ) throws -> Data { + let frontMatterString = try encodeFrontMatter(value) + let fullString = [ + frontMatterString, + "", + value.cmark.trimmingCharacters( + in: .whitespacesAndNewlines + ) + ].joined(separator: "\n") + return Data(fullString.utf8) + } + + private func encodeFrontMatter( + _ value: FrontMatterCMark + ) throws -> String { + guard let frontMatter = value.frontMatter else { + return "" + } - var hasFrontMatter: Bool { - false + let yamlEncoder = YAMLEncoder() + yamlEncoder.options = .init(indent: 2, width: 80) + let yaml = try yamlEncoder.encode(frontMatter) + return [ + FrontMatterCMarkUtils.frontMatterDelimiter, + yaml.trimmingCharacters(in: .whitespacesAndNewlines), + FrontMatterCMarkUtils.frontMatterDelimiter + ].joined(separator: "\n") } } public extension String { func trimmingTrailingCharacters(in _: CharacterSet) -> String { - guard let lastIndex = (lastIndex { !CharacterSet(charactersIn: String($0)).isSubset(of: .whitespacesAndNewlines) }) else { + guard let lastIndex = (lastIndex { !CharacterSet(charactersIn: String($0)) + .isSubset(of: .whitespacesAndNewlines) + }) else { return String(self) } @@ -74,6 +144,6 @@ public extension String { } func isFrontMatterDelimiter() -> Bool { - trimmingTrailingCharacters(in: .whitespacesAndNewlines) == FrontMatterCMarkParser.frontMatterDelimiter + trimmingTrailingCharacters(in: .whitespacesAndNewlines) == FrontMatterCMarkUtils.frontMatterDelimiter } } diff --git a/Sources/Hoods/Dummy.swift b/Sources/Hoods/Dummy.swift deleted file mode 100644 index 86ab7ea..0000000 --- a/Sources/Hoods/Dummy.swift +++ /dev/null @@ -1,3 +0,0 @@ -import Foundation - -struct Dummy {} diff --git a/Tests/HoodsTests/DummyTests.swift b/Tests/HoodsTests/DummyTests.swift deleted file mode 100644 index a058b77..0000000 --- a/Tests/HoodsTests/DummyTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -@testable import Hoods -import XCTest - -final class DummyTests: XCTestCase { - func testDummy() throws { - XCTAssert("abc".count == 3) - } -} diff --git a/Tests/HoodsTests/FrontMatterCMarkTests.swift b/Tests/HoodsTests/FrontMatterCMarkTests.swift index d29c1c8..a26e1a8 100644 --- a/Tests/HoodsTests/FrontMatterCMarkTests.swift +++ b/Tests/HoodsTests/FrontMatterCMarkTests.swift @@ -1,39 +1,81 @@ import Foundation -@testable import Hoods +import Hoods import XCTest class FrontMatterCMarkTests: XCTestCase { - func testBasicParsing() throws { + struct DemoFrontMatter: Codable { + let layout: String + let id: String + let title: String + let authors: [String] + let excerpt: String + let category: String + let tags: [String] + } + + func testBasicDecodingAndReEncoding() throws { guard let sampleURL = Bundle.module.url(forResource: "sample-front-matter", withExtension: "md") else { fatalError("Couldnot find URL of a test resource.") } - let data = try Data(contentsOf: sampleURL) + let originalData = try Data(contentsOf: sampleURL) - let parser = try FrontMatterCMarkParser(data: data) - let sut = try parser.parse() - XCTAssertTrue(sut.hasFrontMatter) - XCTAssertEqual(sut.frontMatter["layout"] as! String, "post") - XCTAssertEqual(sut.frontMatter["id"] as! String, "efee651d-ae92-4dfb-9009-16141b4e3dcb") - XCTAssertEqual(sut.frontMatter["title"] as! String, "Dummy Title") - XCTAssertEqual(sut.frontMatter["authors"] as! [String], ["This Author"]) - XCTAssertEqual(sut.frontMatter["excerpt"] as! String, "An excerpt") - XCTAssertEqual(sut.frontMatter["category"] as! String, "A metadata category") - XCTAssertEqual(sut.frontMatter["tags"] as! [String], ["A metadata tag"]) + let sut = try FrontMatterCMarkDecoder().decode(DemoFrontMatter.self, from: originalData) + let frontMatter = try XCTUnwrap(sut.frontMatter) + XCTAssertEqual(frontMatter.layout, "post") + XCTAssertEqual(frontMatter.id, "efee651d-ae92-4dfb-9009-16141b4e3dcb") + XCTAssertEqual(frontMatter.title, "Dummy Title") + XCTAssertEqual(frontMatter.authors, ["This Author"]) + XCTAssertEqual(frontMatter.excerpt, "Lorem ipsum dolor' sit \"amet\": consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Semper eget duis at tellus. Magnis dis parturient montes nascetur ridiculus mus mauris vitae ultricies. In eu mi bibendum neque egestas. Tortor consequat id porta nibh venenatis. Nibh tortor id aliquet lectus proin nibh nisl.") + XCTAssertEqual(frontMatter.category, "A metadata category") + XCTAssertEqual(frontMatter.tags, ["A metadata tag"]) XCTAssertEqual( sut.cmark, """ - Some markdown. + Some Markdown. - * List item 1 - * List item 2 + - List item 1 + - List item 2 ## A header of level 2 Some more text. """.trimmingCharacters(in: .whitespacesAndNewlines) ) + + let dataFromSut = try FrontMatterCMarkEncoder().encode(sut) + let stringFromDataSut = try XCTUnwrap(String(data: dataFromSut, encoding: .utf8)) + + XCTAssertEqual( + stringFromDataSut, + """ + --- + layout: post + id: efee651d-ae92-4dfb-9009-16141b4e3dcb + title: Dummy Title + authors: + - This Author + excerpt: 'Lorem ipsum dolor'' sit "amet": consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Semper eget duis at tellus. + Magnis dis parturient montes nascetur ridiculus mus mauris vitae ultricies. In eu + mi bibendum neque egestas. Tortor consequat id porta nibh venenatis. Nibh tortor + id aliquet lectus proin nibh nisl.' + category: A metadata category + tags: + - A metadata tag + --- + + Some Markdown. + + - List item 1 + - List item 2 + + ## A header of level 2 + + Some more text. + """ + ) } func testTrimmingTrailingCharactersExtension() throws { diff --git a/Tests/HoodsTests/Resources/sample-front-matter.md b/Tests/HoodsTests/Resources/sample-front-matter.md index b309d39..3ab8400 100644 --- a/Tests/HoodsTests/Resources/sample-front-matter.md +++ b/Tests/HoodsTests/Resources/sample-front-matter.md @@ -5,16 +5,20 @@ title: Dummy Title authors: - This Author excerpt: >- - An excerpt + Lorem ipsum dolor' sit "amet": consectetur adipiscing elit, sed do eiusmod + tempor incididunt ut labore et dolore magna aliqua. Semper eget duis at + tellus. Magnis dis parturient montes nascetur ridiculus mus mauris vitae + ultricies. In eu mi bibendum neque egestas. Tortor consequat id porta nibh + venenatis. Nibh tortor id aliquet lectus proin nibh nisl. category: A metadata category tags: - A metadata tag --- -Some markdown. +Some Markdown. -* List item 1 -* List item 2 +- List item 1 +- List item 2 ## A header of level 2