Skip to content

Commit

Permalink
Merge pull request #5 from dirtyhenry/improve-cmark
Browse files Browse the repository at this point in the history
Improve FrontMatter CMark tooling
  • Loading branch information
dirtyhenry authored Nov 22, 2023
2 parents d8ebdc3 + 6a59302 commit ae9e69a
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 61 deletions.
1 change: 1 addition & 0 deletions .prettierrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
proseWrap: always
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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:

Expand Down Expand Up @@ -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
128 changes: 99 additions & 29 deletions Sources/Hoods/CMS/FrontMatterCMark.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,78 +2,148 @@ 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<FrontMatter: Codable> {
/// 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>(
_: FrontMatter.Type,
from data: Data
) throws -> FrontMatterCMark<FrontMatter> 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<some Encodable>
) 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<some Encodable>
) 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)
}

return String(self[...lastIndex])
}

func isFrontMatterDelimiter() -> Bool {
trimmingTrailingCharacters(in: .whitespacesAndNewlines) == FrontMatterCMarkParser.frontMatterDelimiter
trimmingTrailingCharacters(in: .whitespacesAndNewlines) == FrontMatterCMarkUtils.frontMatterDelimiter
}
}
3 changes: 0 additions & 3 deletions Sources/Hoods/Dummy.swift

This file was deleted.

8 changes: 0 additions & 8 deletions Tests/HoodsTests/DummyTests.swift

This file was deleted.

74 changes: 58 additions & 16 deletions Tests/HoodsTests/FrontMatterCMarkTests.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions Tests/HoodsTests/Resources/sample-front-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

0 comments on commit ae9e69a

Please sign in to comment.