diff --git a/Package.resolved b/Package.resolved index d3d55a8b..4051f2a2 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,70 +1,150 @@ { - "object": { - "pins": [ - { - "package": "Codextended", - "repositoryURL": "https://github.com/johnsundell/codextended.git", - "state": { - "branch": null, - "revision": "8d7c46dfc9c55240870cf5561d6cefa41e3d7105", - "version": "0.3.0" - } - }, - { - "package": "CollectionConcurrencyKit", - "repositoryURL": "https://github.com/johnsundell/collectionConcurrencyKit.git", - "state": { - "branch": null, - "revision": "2e4984dcaed6432f4eff175f6616ba463428cd8a", - "version": "0.1.0" - } - }, - { - "package": "Files", - "repositoryURL": "https://github.com/johnsundell/files.git", - "state": { - "branch": null, - "revision": "d273b5b7025d386feef79ef6bad7de762e106eaf", - "version": "4.2.0" - } - }, - { - "package": "Ink", - "repositoryURL": "https://github.com/johnsundell/ink.git", - "state": { - "branch": null, - "revision": "77c3d8953374a9cf5418ef0bd7108524999de85a", - "version": "0.5.1" - } - }, - { - "package": "Plot", - "repositoryURL": "https://github.com/johnsundell/plot.git", - "state": { - "branch": null, - "revision": "80612b34252188edbef280e5375e2fc5249ac770", - "version": "0.9.0" - } - }, - { - "package": "ShellOut", - "repositoryURL": "https://github.com/johnsundell/shellout.git", - "state": { - "branch": null, - "revision": "e1577acf2b6e90086d01a6d5e2b8efdaae033568", - "version": "2.3.0" - } - }, - { - "package": "Sweep", - "repositoryURL": "https://github.com/johnsundell/sweep.git", - "state": { - "branch": null, - "revision": "801c2878e4c6c5baf32fe132e1f3f3af6f9fd1b0", - "version": "0.4.0" - } - } - ] - }, - "version": 1 + "originHash" : "06c840015557163f70247b520568a7a9b5809bce51dcb50e7600683ec69a0986", + "pins" : [ + { + "identity" : "codextended", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnsundell/codextended.git", + "state" : { + "revision" : "8d7c46dfc9c55240870cf5561d6cefa41e3d7105", + "version" : "0.3.0" + } + }, + { + "identity" : "collectionconcurrencykit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnsundell/collectionConcurrencyKit.git", + "state" : { + "revision" : "b4f23e24b5a1bff301efc5e70871083ca029ff95", + "version" : "0.2.0" + } + }, + { + "identity" : "files", + "kind" : "remoteSourceControl", + "location" : "https://github.com/peterkovacs/files", + "state" : { + "branch" : "master", + "revision" : "5f20560292f1d683e799dc201130b775668fa2c7" + } + }, + { + "identity" : "plot", + "kind" : "remoteSourceControl", + "location" : "https://github.com/peterkovacs/plot", + "state" : { + "branch" : "swift-6", + "revision" : "4084e7fe07cfd312824548969ec86ff5d1d03236" + } + }, + { + "identity" : "shellout", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnsundell/shellout.git", + "state" : { + "revision" : "e1577acf2b6e90086d01a6d5e2b8efdaae033568", + "version" : "2.3.0" + } + }, + { + "identity" : "sweep", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnsundell/sweep.git", + "state" : { + "revision" : "801c2878e4c6c5baf32fe132e1f3f3af6f9fd1b0", + "version" : "0.4.0" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-case-paths", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-case-paths", + "state" : { + "revision" : "41b89b8b68d8c56c622dbb7132258f1a3e638b25", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-markdown", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-markdown.git", + "state" : { + "revision" : "ea79e83c8744d2b50b0dc2d5bbd1e857e1253bf9", + "version" : "0.6.0" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "34d486b01cd891297ac615e40d5999536a1e138d", + "version" : "2.83.0" + } + }, + { + "identity" : "swift-parsing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-parsing.git", + "state" : { + "revision" : "3432cb81164dd3d69a75d0d63205be5fbae2c34b", + "version" : "0.14.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "61e4ca4b81b9e09e2ec863b00c340eb13497dac6", + "version" : "1.5.0" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index f81064cc..74fb6cef 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.1 /** * Publish @@ -10,54 +10,63 @@ import PackageDescription let package = Package( name: "Publish", - platforms: [.macOS(.v12)], + platforms: [.macOS(.v15), .iOS(.v16)], products: [ .library(name: "Publish", targets: ["Publish"]), + .library(name: "MarkdownParser", targets: ["MarkdownParser"]), .executable(name: "publish-cli", targets: ["PublishCLI"]) ], dependencies: [ .package( - name: "Ink", - url: "https://github.com/johnsundell/ink.git", - from: "0.2.0" + url: "https://github.com/peterkovacs/plot", + branch: "swift-6" ), .package( - name: "Plot", - url: "https://github.com/johnsundell/plot.git", - from: "0.9.0" + url: "https://github.com/peterkovacs/files", + branch: "master" ), .package( - name: "Files", - url: "https://github.com/johnsundell/files.git", - from: "4.0.0" - ), - .package( - name: "Codextended", url: "https://github.com/johnsundell/codextended.git", from: "0.1.0" ), .package( - name: "ShellOut", url: "https://github.com/johnsundell/shellout.git", from: "2.3.0" ), .package( - name: "Sweep", url: "https://github.com/johnsundell/sweep.git", from: "0.4.0" ), .package( - name: "CollectionConcurrencyKit", url: "https://github.com/johnsundell/collectionConcurrencyKit.git", from: "0.1.0" - ) + ), + .package(url: "https://github.com/swiftlang/swift-markdown.git", from: "0.6.0"), + .package(url: "https://github.com/pointfreeco/swift-parsing.git", from: "0.12.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.83.0"), ], targets: [ .target( name: "Publish", dependencies: [ - "Ink", "Plot", "Files", "Codextended", - "ShellOut", "Sweep", "CollectionConcurrencyKit" + .product(name: "Plot", package: "plot"), + .product(name: "Files", package: "files"), + .product(name: "Codextended", package: "codextended"), + .product(name: "ShellOut", package: "shellout"), + .product(name: "Sweep", package: "sweep"), + .product(name: "CollectionConcurrencyKit", package: "collectionConcurrencyKit"), + .product(name: "NIO", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "_NIOFileSystem", package: "swift-nio"), + "MarkdownParser", + ] + ), + .target( + name: "MarkdownParser", + dependencies: [ + .product(name: "Plot", package: "plot"), + .product(name: "Markdown", package: "swift-markdown"), + .product(name: "Parsing", package: "swift-parsing") ] ), .executableTarget( @@ -70,7 +79,15 @@ let package = Package( ), .testTarget( name: "PublishTests", - dependencies: ["Publish", "PublishCLICore"] + dependencies: ["Publish", "PublishCLICore"], + swiftSettings: [ +// .define("INCLUDE_CLI") + ] + ), + .testTarget( + name: "MarkdownParserTests", + dependencies: ["MarkdownParser", "Publish"] ) + ] ) diff --git a/Sources/MarkdownParser/MarkdownDocument.swift b/Sources/MarkdownParser/MarkdownDocument.swift new file mode 100644 index 00000000..4985c35d --- /dev/null +++ b/Sources/MarkdownParser/MarkdownDocument.swift @@ -0,0 +1,24 @@ +import Foundation +import Plot + +public struct MarkdownDocument: Identifiable { + public var id: UUID + public var body: Node = .empty + public var title: String? = nil + public var description: Node? = nil + public var metadata: [String: String] = .init() + + public init( + id: UUID = .init(), + body: Node = .empty, + title: String? = nil, + description: Node? = nil, + metadata: [String : String] = [:] + ) { + self.id = id + self.body = body + self.title = title + self.description = description + self.metadata = metadata + } +} diff --git a/Sources/MarkdownParser/MarkdownParser.swift b/Sources/MarkdownParser/MarkdownParser.swift new file mode 100644 index 00000000..12a9a55a --- /dev/null +++ b/Sources/MarkdownParser/MarkdownParser.swift @@ -0,0 +1,122 @@ +import Foundation +import Markdown +import Parsing + +public struct MarkdownParser: Sendable { + private var modifiers: ModifierCollection + + public init() { + self.modifiers = .init(modifiers: []) + } + + public mutating func addModifier( + for target: Modifier.Target, + modifier: @escaping Modifier.Closure, + file: StaticString = #file, + line: Int = #line + ) { + modifiers.insert(.init(target: target, closure: modifier, file: file, line: line)) + } + + public func html(from markdown: String) -> String { + parse(markdown).body.render() + } + + public func parse(_ markdown: String) -> MarkdownDocument { + var visitor = Visitor(modifiers: modifiers) + var markdown = markdown + + do { + (visitor.document.metadata, markdown) = try markdownMetadata.parse(markdown) + } catch let error { + print(error) + } + + visitor.parse( + markdown: Markdown.Document( + parsing: markdown, + options: [.parseBlockDirectives] + ) + ) + + return visitor.document + } + + private var identifier: some Parser { + Parse { (first: Substring, rest: Substring) in + "\(first)\(rest)" + } with: { + CharacterSet.letters + Prefix { + $0.unicodeScalars.allSatisfy { CharacterSet.alphanumerics.contains($0) } + } + } + } + + private var dottedIdentifier: some Parser { + Many(into: "") { (s: inout String, e: String) in + if !s.isEmpty { s.append(".") } + s.append(e) + } element: { + identifier + } separator: { + "." + } + } + + private var keyAndValue: some Parser { + Parse { + Whitespace(.horizontal) + + dottedIdentifier + + Whitespace(.horizontal) + ":" + Whitespace(.horizontal) + + Prefix { + $0 != "\r" && $0 != "\n" + } + .map(.string) + .map { $0.trimmingCharacters(in: .whitespaces) } + } + } + + private var metadataDictionary: some Parser { + Many(into: [:]) { + $0[$1.0] = $1.1 + } element: { + keyAndValue + } separator: { + Whitespace(.vertical) + } + } + + private var separator: some Parser { + Skip { + Whitespace(.vertical) + "---" + OneOf { + Whitespace(.vertical) + End() + } + } + } + + private var markdownMetadata: some Parser { + Parse { + Optionally { + separator + metadataDictionary + separator + }.map { + $0 ?? [:] as [String: String] + } + + OneOf { + End().map { "" } + Rest().map(.string) + } + } + } +} diff --git a/Sources/MarkdownParser/MarkupVisitor.swift b/Sources/MarkdownParser/MarkupVisitor.swift new file mode 100644 index 00000000..2c984060 --- /dev/null +++ b/Sources/MarkdownParser/MarkupVisitor.swift @@ -0,0 +1,490 @@ +import Foundation +import Markdown +import Parsing +import Plot + +fileprivate extension Node where Context == HTML.BodyContext { + static func element( + named: String, + children: MarkupChildren, + visitor: inout Visitor + ) -> Self + where Visitor.Result == Self + { + .element(named: named, nodes: children.map { $0.accept(&visitor) }) + } +} + +struct Visitor: MarkupVisitor { + typealias Result = Node + + var modifiers: ModifierCollection + var document: MarkdownDocument + + init(modifiers: ModifierCollection) { + self.modifiers = modifiers + self.document = .init(body: .empty, metadata: [:]) + } + + mutating func parse(markdown: Markdown.Document) { + self.document.body = markdown.accept(&self) + } +} + +extension Visitor { + public mutating func defaultVisit(_ markup: Markdown.Markup) -> Result { + .group( + markup.children.map { $0.accept(&self) } + ) + } + + public mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { + var html = Result.element(named: "blockquote", children: blockQuote.children, visitor: &self) + + modifiers.applyModifiers(for: .blockQuote) { modifier in + html = modifier.closure( + html, + &document, + blockQuote + ) + } + + return html + } + + public mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { + var html = Result.element( + named: "pre", + nodes: [ + .element( + named: "code", + nodes: [ + .attribute( + named: "class", + value: codeBlock.language.map { "language-\($0)" }, + ignoreIfValueIsEmpty: true + ), + .text(codeBlock.code) + ] + ) + ] + ) + + modifiers.applyModifiers(for: .codeBlock) { modifier in + html = modifier.closure( + html, + &document, + codeBlock + ) + } + + return html + } + + public mutating func visitDocument(_ document: Markdown.Document) -> Node { + var html = Result.group( + document.children.map { $0.accept(&self) } + ) + + modifiers.applyModifiers(for: .document) { + html = $0.closure( + html, + &self.document, + document + ) + } + + return html + } + + public mutating func visitHeading(_ heading: Heading) -> Node { + var html = Result.element(named: "h\(heading.level)", children: heading.children, visitor: &self) + + modifiers.applyModifiers(for: .heading) { + html = $0.closure( + html, + &document, + heading + ) + } + + if heading.level == 1, self.document.title == nil { + self.document.title = heading.plainText + + modifiers.applyModifiers(for: .title) { + html = $0.closure( + html, + &document, + heading + ) + } + } + + return html + } + + public mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Node { + var html = Result.selfClosedElement(named: "hr") + + modifiers.applyModifiers(for: .thematicBreak) { + html = $0.closure( + html, + &document, + thematicBreak + ) + } + + return html + } + + public mutating func visitHTMLBlock(_ htmlBlock: HTMLBlock) -> Node { + var html = Result.raw(htmlBlock.rawHTML) + + modifiers.applyModifiers(for: .htmlBlock) { + html = $0.closure( + html, + &document, + htmlBlock + ) + } + + return html + } + + public mutating func visitListItem(_ listItem: Markdown.ListItem) -> Node { + var html = Result.element(named: "li", children: listItem.children, visitor: &self) + + modifiers.applyModifiers(for: .listItem) { + html = $0.closure( + html, + &document, + listItem + ) + } + + return html + } + + public mutating func visitOrderedList(_ orderedList: OrderedList) -> Node { + var nodes = [ + Result.attribute( + named: "start", + value: orderedList.startIndex > 1 ? "\(orderedList.startIndex)" : nil, + ignoreIfValueIsEmpty: true + ) + ] + + nodes.append(contentsOf: orderedList.children.map { $0.accept(&self) }) + + var html = Result.element( + named: "ol", + nodes: nodes + ) + + modifiers.applyModifiers(for: .orderedList) { + html = $0.closure( + html, + &document, + orderedList + ) + } + + return html + } + + public mutating func visitUnorderedList(_ unorderedList: Markdown.UnorderedList) -> Node { + var html = Result.element(named: "ul", children: unorderedList.children, visitor: &self) + + modifiers.applyModifiers(for: .unorderedList) { + html = $0.closure( + html, + &document, + unorderedList + ) + } + + return html + } + + public mutating func visitParagraph(_ paragraph: Markdown.Paragraph) -> Node { + var html: Result + if paragraph.parent is Markdown.ListItem { + html = Result.group(paragraph.children.map { $0.accept(&self) }) + } else { + html = Result.element(named: "p", children: paragraph.children, visitor: &self) + } + + modifiers.applyModifiers(for: .paragraph) { + html = $0.closure( + html, + &document, + paragraph + ) + } + + return html + } + + public mutating func visitBlockDirective(_ blockDirective: BlockDirective) -> Node { + // TODO: Need to parse the contents of the blockDirective that will end up setting data in Self. + + var html = Result.empty + modifiers.applyModifiers( + for: .blockDirective(blockDirective.name) + ) { + html = $0.closure( + .empty, + &document, + blockDirective + ) + } + + return html + } + + public mutating func visitInlineCode(_ inlineCode: InlineCode) -> Node { + var html = Result.element(named: "code", text: inlineCode.code) + + modifiers.applyModifiers(for: .inlineCode) { + html = $0.closure( + html, + &document, + inlineCode + ) + } + + return html + } + + public mutating func visitEmphasis(_ emphasis: Emphasis) -> Node { + var html = Result.element(named: "em", children: emphasis.children, visitor: &self) + + modifiers.applyModifiers(for: .emphasis) { + html = $0.closure( + html, + &document, + emphasis + ) + } + + return html + } + + private var imageSourceParser: some Parser { + OneOf { + Parse { (a: Substring, b: Substring) -> ImageSource? in + URL(string: "\(a)\(b)").map(ImageSource.url) + } with: { + PrefixUpTo("://") + Rest() + } + + Parse { (path: Substring) -> ImageSource in + ImageSource.path("\(path)") + } with: { + Rest() + } + } + } + + private enum ImageSource { + case path(String) + case url(URL) + } + + public mutating func visitImage(_ image: Markdown.Image) -> Node { + guard let source = image.source else { return .empty } + + var html = Result.selfClosedElement( + named: "img", + attributes: [ + .src(source), + .alt(image.plainText) + ] + ) + + guard let imageSource = try? imageSourceParser.parse(source[...]) else { + modifiers.applyModifiers(for: .remoteImage) { + html = $0.closure( + html, + &document, + image + ) + } + + return html + } + + switch imageSource { + case .path: + modifiers.applyModifiers(for: .localImage) { + html = $0.closure( + html, + &document, + image + ) + } + case .url: + modifiers.applyModifiers(for: .remoteImage) { + html = $0.closure( + html, + &document, + image + ) + } + } + + return html + } + + public mutating func visitInlineHTML(_ inlineHTML: Markdown.InlineHTML) -> Node { + var html = Result.raw(inlineHTML.rawHTML) + + modifiers.applyModifiers(for: .inlineHTML) { + html = $0.closure( + html, + &document, + inlineHTML + ) + } + + return html + } + + public mutating func visitLineBreak(_ lineBreak: Markdown.LineBreak) -> Node { + var html = Result.selfClosedElement(named: "br") + + modifiers.applyModifiers(for: .lineBreak) { + html = $0.closure( + html, + &document, + lineBreak + ) + } + + return html + } + + public mutating func visitLink(_ link: Markdown.Link) -> Node { + var html = Result.element( + named: "a", + nodes: [ + .attribute(named: "href", value: link.destination, ignoreIfValueIsEmpty: true) + ] + link.children.map { $0.accept(&self) } + ) + + modifiers.applyModifiers(for: .link) { + html = $0.closure( + html, + &document, + link + ) + } + + return html + } + + public mutating func visitSoftBreak(_ softBreak: SoftBreak) -> Node { + var html = Result.raw(" ") + + modifiers.applyModifiers(for: .softBreak) { + html = $0.closure( + html, + &document, + softBreak + ) + } + + return html + } + + public mutating func visitStrong(_ strong: Markdown.Strong) -> Node { + var html = Result.element(named: "strong", children: strong.children, visitor: &self) + + modifiers.applyModifiers(for: .strong) { + html = $0.closure( + html, + &document, + strong + ) + } + + return html + } + + public mutating func visitText(_ text: Markdown.Text) -> Node { + var html = Result.text(text.string) + + modifiers.applyModifiers(for: .text) { + html = $0.closure( + html, + &document, + text + ) + } + + return html + } + + public mutating func visitStrikethrough(_ strikethrough: Strikethrough) -> Node { + var html = Result.element(named: "s", children: strikethrough.children, visitor: &self) + + modifiers.applyModifiers(for: .strikethrough) { + html = $0.closure( + html, + &document, + strikethrough + ) + } + + return html + } + + public mutating func visitTable(_ table: Markdown.Table) -> Node { + let columnAlignments = table.columnAlignments + + func columnAlignment(_ alignment: Markdown.Table.ColumnAlignment?) -> String? { + switch alignment { + case .left: return "left" + case .center: return "center" + case .right: return "right" + case nil: return nil + } + } + + func visitCells(named: String, tableCells: LazyMapSequence) -> Node { + var nodes: [Node] = [] + for (i, cell) in tableCells.enumerated() { + nodes.append( + .element( + named: named, + nodes: [ + .attribute(named: "align", value: columnAlignments[i].flatMap(columnAlignment), ignoreIfValueIsEmpty: true), + .if(cell.colspan > 1, .attribute(named: "colspan", value: "\(cell.colspan)")), + .if(cell.rowspan > 1, .attribute(named: "rowspan", value: "\(cell.rowspan)")), + cell.accept(&self) + ] + ) + ) + } + + return .element(named: "tr", nodes: nodes) + } + + return Result.table( + .element( + named: "thead", + nodes: [ + visitCells(named: "th", tableCells: table.head.cells) + ] + ), + .if( + !table.body.isEmpty, + .element( + named: "tbody", + nodes: table.body.rows.map { visitCells(named: "td", tableCells: $0.cells) } + ) + ) + ) + } +} diff --git a/Sources/MarkdownParser/Modifier.swift b/Sources/MarkdownParser/Modifier.swift new file mode 100644 index 00000000..5169b30d --- /dev/null +++ b/Sources/MarkdownParser/Modifier.swift @@ -0,0 +1,78 @@ +// +// File.swift +// +// +// Created by Peter Kovacs on 6/21/23. +// + +import Foundation +import Plot +import Markdown + +public struct Modifier: Sendable { + public typealias Closure = @Sendable ( + Node, + inout MarkdownDocument, + Markup + ) -> Node + public var target: Target + public var closure: Closure + public var file: StaticString + public var line: Int + + public init(target: Target, closure: @escaping Closure, file: StaticString = #file, line: Int = #line) { + self.target = target + self.closure = closure + self.file = file + self.line = line + } +} + +public extension Modifier { + enum Target: Hashable, Sendable { + case blockQuote + case codeBlock + case document + case title + case heading + case thematicBreak + case htmlBlock + case listItem + case orderedList + case unorderedList + case paragraph + case blockDirective(String) + case inlineCode + case emphasis + case localImage + case remoteImage + case inlineHTML + case lineBreak + case link + case softBreak + case strong + case text + case strikethrough + } +} + +internal struct ModifierCollection: Sendable { + private var modifiers: [Modifier.Target : [Modifier]] + + init(modifiers: [Modifier]) { + self.modifiers = Dictionary( + grouping: modifiers, + by: \.target + ) + } + + func applyModifiers(for target: Modifier.Target, + using closure: (Modifier) -> Void) { + modifiers[target]?.forEach(closure) + } + + mutating func insert(_ modifier: Modifier) { + modifiers[modifier.target, default: []].append(modifier) + } +} + diff --git a/Sources/Publish/API/AnyItem.swift b/Sources/Publish/API/AnyItem.swift index f316c9be..03ff0572 100644 --- a/Sources/Publish/API/AnyItem.swift +++ b/Sources/Publish/API/AnyItem.swift @@ -10,7 +10,7 @@ import Foundation /// when implementing general-purpose themes or utilities. It /// doesn't contain site-specific information, such as the item's /// metadata or section ID. -public protocol AnyItem: Location { +public protocol AnyItem: Location, Sendable { /// The item's tags. Items tagged with the same tag can be /// queried using either `Section` or `PublishingContext`. var tags: [Tag] { get } diff --git a/Sources/Publish/API/Audio.swift b/Sources/Publish/API/Audio.swift index 910e5772..67dcecd5 100644 --- a/Sources/Publish/API/Audio.swift +++ b/Sources/Publish/API/Audio.swift @@ -11,7 +11,7 @@ import Codextended /// A representation of a location's audio data. Can be used to /// implement podcast feeds, or inline audio players using the /// `audioPlayer` Plot component. -public struct Audio: Hashable { +public struct Audio: Hashable, Sendable { /// The URL of the audio. Should be an absolute URL. public var url: URL /// The format of the audio. See `HTMLAudioFormat`. @@ -39,7 +39,7 @@ public struct Audio: Hashable { public extension Audio { /// A representation of an audio file's duration. - struct Duration: Hashable { + struct Duration: Hashable, Sendable { /// The duration's number of hours. public var hours: Int /// The duration's number of minutes. diff --git a/Sources/Publish/API/Content.swift b/Sources/Publish/API/Content.swift index 0372eddc..d31e869c 100644 --- a/Sources/Publish/API/Content.swift +++ b/Sources/Publish/API/Content.swift @@ -8,7 +8,7 @@ import Foundation import Plot /// Type representing a location's main content. -public struct Content: Hashable, ContentProtocol { +public struct Content: ContentProtocol, Hashable, Sendable { public var title: String public var description: String public var body: Body @@ -48,18 +48,23 @@ public struct Content: Hashable, ContentProtocol { public extension Content { /// Type that represents the main renderable body of a piece of content. - struct Body: Hashable { + struct Body { /// The content's renderable HTML. - public var html: String - /// A node that can be used to embed the content in a Plot hierarchy. - public var node: Node { .raw(html) } + public var node: Node + + private var indentation: Indentation.Kind? = nil + + public var html: String { + node.render(indentedBy: indentation) + } + /// Whether this value doesn't contain any content. - public var isEmpty: Bool { html.isEmpty } /// Initialize an instance with a ready-made HTML string. /// - parameter html: The content HTML that the instance should cointain. public init(html: String) { - self.html = html + self.node = .raw(html) + self.indentation = nil } /// Initialize an instance with a Plot `Node`. @@ -67,7 +72,8 @@ public extension Content { /// - parameter indentation: Any indentation to apply when rendering the node. public init(node: Node, indentation: Indentation.Kind? = nil) { - html = node.render(indentedBy: indentation) + self.node = node + self.indentation = indentation } /// Initialize an instance using Plot's `Component` API. @@ -75,8 +81,10 @@ public extension Content { /// - parameter components: The components that should make up this instance's content. public init(indentation: Indentation.Kind? = nil, @ComponentBuilder components: () -> Component) { - self.init(node: .component(components()), - indentation: indentation) + self.init( + node: .component(components()), + indentation: indentation + ) } } } @@ -90,3 +98,13 @@ extension Content.Body: ExpressibleByStringInterpolation { extension Content.Body: Component { public var body: Component { node } } + +extension Content.Body: Hashable { + public static func ==(lhs: Self, rhs: Self) -> Bool { + lhs.html == rhs.html + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(html) + } +} diff --git a/Sources/Publish/API/ContentProtocol.swift b/Sources/Publish/API/ContentProtocol.swift index 775646b3..1a2da18a 100644 --- a/Sources/Publish/API/ContentProtocol.swift +++ b/Sources/Publish/API/ContentProtocol.swift @@ -7,7 +7,7 @@ import Foundation /// Protocol adopted by types that represent the content for a location. -public protocol ContentProtocol { +public protocol ContentProtocol: Sendable { /// The location's title. When parsing a location from Markdown, /// the top-level H1 heading will be used as the location's title, /// which can also be overridden using the `title` metadata key. diff --git a/Sources/Publish/API/DeploymentMethod.swift b/Sources/Publish/API/DeploymentMethod.swift index 81dedb18..f3c40d24 100644 --- a/Sources/Publish/API/DeploymentMethod.swift +++ b/Sources/Publish/API/DeploymentMethod.swift @@ -13,11 +13,11 @@ import ShellOut /// frameworks or APIs, it's recommended to create them using static /// factory methods, just like how the built-in `git` and `gitHub` /// deployment methods are implemented. -public struct DeploymentMethod { +public struct DeploymentMethod: Sendable { /// Closure type used to implement the deployment method's main /// body. It's passed the `PublishingContext` of the current /// session, and can use that to create a dedicated deployment folder. - public typealias Body = (PublishingContext) throws -> Void + public typealias Body = @Sendable (PublishingContext) throws -> Void /// The human-readable name of the deployment method. public var name: String diff --git a/Sources/Publish/API/Exports.swift b/Sources/Publish/API/Exports.swift new file mode 100644 index 00000000..6a1c44fd --- /dev/null +++ b/Sources/Publish/API/Exports.swift @@ -0,0 +1 @@ +@_exported import MarkdownParser diff --git a/Sources/Publish/API/Favicon.swift b/Sources/Publish/API/Favicon.swift index 5efd8359..98bbc7b8 100644 --- a/Sources/Publish/API/Favicon.swift +++ b/Sources/Publish/API/Favicon.swift @@ -8,7 +8,7 @@ import Foundation /// A representation of a website's "favicon" (a small icon typically /// displayed along the website's title in various browser UIs). -public struct Favicon { +public struct Favicon: Sendable { /// The favicon's absolute path. public var path: Path /// The MIME type of the image. diff --git a/Sources/Publish/API/FeedConfiguration.swift b/Sources/Publish/API/FeedConfiguration.swift index dca39a11..83586ebd 100644 --- a/Sources/Publish/API/FeedConfiguration.swift +++ b/Sources/Publish/API/FeedConfiguration.swift @@ -9,7 +9,7 @@ import Plot /// Protocol that acts as a shared API for configuring various feed /// generation steps, such as `generateRSSFeed` and `generatePodcastFeed`. -public protocol FeedConfiguration: Codable, Equatable { +public protocol FeedConfiguration: Codable, Equatable, Sendable { /// The path that the feed should be generated at. var targetPath: Path { get } /// The feed's TTL (or "Time to live") time interval. diff --git a/Sources/Publish/API/HTMLFactory.swift b/Sources/Publish/API/HTMLFactory.swift index a03888af..be0b2977 100644 --- a/Sources/Publish/API/HTMLFactory.swift +++ b/Sources/Publish/API/HTMLFactory.swift @@ -8,7 +8,7 @@ import Plot /// Protocol used to implement a website theme's underlying factory, /// that creates HTML for a site's various locations using the Plot DSL. -public protocol HTMLFactory { +public protocol HTMLFactory: Sendable { /// The website that the factory is for. Generic constraints may be /// applied to this type to require that a website fulfills certain /// requirements in order to use this factory. diff --git a/Sources/Publish/API/HTMLFileMode.swift b/Sources/Publish/API/HTMLFileMode.swift index ab26aac6..61fd128b 100644 --- a/Sources/Publish/API/HTMLFileMode.swift +++ b/Sources/Publish/API/HTMLFileMode.swift @@ -7,7 +7,7 @@ import Foundation /// Enum describing various ways that HTML files may be generated. -public enum HTMLFileMode { +public enum HTMLFileMode: Sendable { /// Stand-alone HTML files should be generated, so that `section/item` /// becomes `section/item.html`. case standAloneFiles diff --git a/Sources/Publish/API/Index.swift b/Sources/Publish/API/Index.swift index 0831757a..4f24db98 100644 --- a/Sources/Publish/API/Index.swift +++ b/Sources/Publish/API/Index.swift @@ -7,7 +7,7 @@ import Foundation /// A representation of a website's main index page -public struct Index: Location { +public struct Index: Location, Sendable { public var path: Path { "" } public var content = Content() } diff --git a/Sources/Publish/API/Item.swift b/Sources/Publish/API/Item.swift index 267d684a..8d313eab 100644 --- a/Sources/Publish/API/Item.swift +++ b/Sources/Publish/API/Item.swift @@ -11,7 +11,7 @@ import Foundation /// article lists, podcasts, and so on. To implement free-form pages, use /// the `Page` type. Items can either be added programmatically, or through /// Markdown files placed in their corresponding section's folder. -public struct Item: AnyItem, Hashable { +public struct Item: AnyItem, Hashable, Sendable { /// The ID of the section that the item belongs to, as defined by the /// `Website` that this item is for. public internal(set) var sectionID: Site.SectionID diff --git a/Sources/Publish/API/ItemRSSProperties.swift b/Sources/Publish/API/ItemRSSProperties.swift index e08ad005..6fbd0521 100644 --- a/Sources/Publish/API/ItemRSSProperties.swift +++ b/Sources/Publish/API/ItemRSSProperties.swift @@ -7,7 +7,7 @@ import Foundation /// Properties that can be used to customize an item's RSS representation. -public struct ItemRSSProperties: Codable, Hashable { +public struct ItemRSSProperties: Codable, Hashable, Sendable { /// Any specific GUID that should be added for the item. When `nil`, /// the item's URL will be used and the `isPermaLink` attribute will /// be set to `true`, unless an explicit `link` was specified. If this diff --git a/Sources/Publish/API/Location.swift b/Sources/Publish/API/Location.swift index e6f78136..f31b594d 100644 --- a/Sources/Publish/API/Location.swift +++ b/Sources/Publish/API/Location.swift @@ -8,7 +8,7 @@ import Foundation /// Protocol adopted by types that can act as a location /// that a user can navigate to within a web browser. -public protocol Location: ContentProtocol { +public protocol Location: ContentProtocol, Sendable { /// The absolute path of the location within the website, /// excluding its base URL. For example, an item "article" /// contained within a section "mySection" will have the diff --git a/Sources/Publish/API/Mutations.swift b/Sources/Publish/API/Mutations.swift index 738133bd..c08e4be3 100644 --- a/Sources/Publish/API/Mutations.swift +++ b/Sources/Publish/API/Mutations.swift @@ -5,4 +5,7 @@ */ /// Closure type used to implement content mutations. -public typealias Mutations = (inout T) throws -> Void +public typealias Mutations = @Sendable (inout T) throws -> Void + +/// Closure type used to implement asynchronous content mutations. +public typealias AsyncMutations = @Sendable (inout T) async throws -> Void diff --git a/Sources/Publish/API/Page.swift b/Sources/Publish/API/Page.swift index 59d6533b..62055d54 100644 --- a/Sources/Publish/API/Page.swift +++ b/Sources/Publish/API/Page.swift @@ -11,7 +11,7 @@ import Foundation /// or lists of pages, that should be organized within sections, use `Section` /// and `Item` instead. Pages can either be added programmatically, or through /// Markdown files placed within the root of the website's content folder. -public struct Page: Location, Equatable { +public struct Page: Location, Equatable, Sendable { public var path: Path public var content: Content diff --git a/Sources/Publish/API/Path.swift b/Sources/Publish/API/Path.swift index 7a3290c0..844bc89c 100644 --- a/Sources/Publish/API/Path.swift +++ b/Sources/Publish/API/Path.swift @@ -8,7 +8,7 @@ import Foundation /// Type used to express a path within a website, either to a /// location or to a resource, such as a file or image. -public struct Path: StringWrapper { +public struct Path: StringWrapper, Sendable { public var string: String public init(_ string: String) { diff --git a/Sources/Publish/API/PlotComponents.swift b/Sources/Publish/API/PlotComponents.swift index 5e0c0b09..69825012 100644 --- a/Sources/Publish/API/PlotComponents.swift +++ b/Sources/Publish/API/PlotComponents.swift @@ -6,8 +6,8 @@ import Foundation import Plot -import Ink import Sweep +import MarkdownParser // MARK: - Nodes and Attributes @@ -94,8 +94,8 @@ public extension Node where Context: HTML.BodyContext { /// - parameter parser: The Markdown parser to use. Pass `context.markdownParser` to /// use the same Markdown parser as the main publishing process is using. static func markdown(_ markdown: String, - using parser: MarkdownParser = .init()) -> Node { - .raw(parser.html(from: markdown)) + using parser: MarkdownParser = .init()) -> Node { + parser.parse(markdown).body } /// Add an inline audio player within the current context. @@ -218,7 +218,7 @@ public extension AudioPlayer { /// You can control what `MarkdownParser` that's used for parsing /// using the `markdownParser` environment key, or by applying the /// `markdownParser` modifier to a component. -public struct Markdown: Component { +public struct MarkdownComponent: Component { /// The Markdown string to render. public var string: String diff --git a/Sources/Publish/API/PlotEnvironmentKeys.swift b/Sources/Publish/API/PlotEnvironmentKeys.swift index 8f86e1d0..5851324f 100644 --- a/Sources/Publish/API/PlotEnvironmentKeys.swift +++ b/Sources/Publish/API/PlotEnvironmentKeys.swift @@ -5,7 +5,7 @@ */ import Plot -import Ink +import MarkdownParser public extension EnvironmentKey where Value == MarkdownParser { /// Environment key that can be used to pass what `MarkdownParser` that diff --git a/Sources/Publish/API/PlotModifiers.swift b/Sources/Publish/API/PlotModifiers.swift index 135cab34..5c5253f4 100644 --- a/Sources/Publish/API/PlotModifiers.swift +++ b/Sources/Publish/API/PlotModifiers.swift @@ -4,8 +4,8 @@ * MIT license, see LICENSE file for details */ -import Ink import Plot +import MarkdownParser public extension Component { /// Assign what `MarkdownParser` to use when rendering `Markdown` components diff --git a/Sources/Publish/API/Plugin.swift b/Sources/Publish/API/Plugin.swift index aa640ad9..72aee908 100644 --- a/Sources/Publish/API/Plugin.swift +++ b/Sources/Publish/API/Plugin.swift @@ -8,7 +8,7 @@ import Foundation /// Type used to implement Publish plugins, that can be used to customize /// the publishing process in any way. -public struct Plugin { +public struct Plugin: Sendable { /// Closure type used to install a plugin within the current context. public typealias Installer = PublishingStep.Closure diff --git a/Sources/Publish/API/PodcastAuthor.swift b/Sources/Publish/API/PodcastAuthor.swift index 000b152c..0cd046bd 100644 --- a/Sources/Publish/API/PodcastAuthor.swift +++ b/Sources/Publish/API/PodcastAuthor.swift @@ -7,7 +7,7 @@ import Foundation /// Type used to describe the author of a podcast. -public struct PodcastAuthor: Codable, Equatable { +public struct PodcastAuthor: Codable, Equatable, Sendable { /// The author's full name. public var name: String /// The author's email address. diff --git a/Sources/Publish/API/PodcastEpisodeMetadata.swift b/Sources/Publish/API/PodcastEpisodeMetadata.swift index 9dd0b6e1..0c963956 100644 --- a/Sources/Publish/API/PodcastEpisodeMetadata.swift +++ b/Sources/Publish/API/PodcastEpisodeMetadata.swift @@ -7,7 +7,7 @@ import Codextended /// Type used to describe metadata for a podcast episode. -public struct PodcastEpisodeMetadata: Hashable { +public struct PodcastEpisodeMetadata: Hashable, Sendable { /// The episode's number. public var episodeNumber: Int? /// The number of the episode's season. diff --git a/Sources/Publish/API/Predicate.swift b/Sources/Publish/API/Predicate.swift index 38674f1c..dffd2d14 100644 --- a/Sources/Publish/API/Predicate.swift +++ b/Sources/Publish/API/Predicate.swift @@ -8,13 +8,13 @@ import Foundation /// Type used to implement predicates that can be used to filter and /// conditionally select items when mutating them. -public struct Predicate { - internal let matches: (Target) -> Bool +public struct Predicate: Sendable { + internal let matches: @Sendable (Target) -> Bool /// Initialize a new predicate instance using a given matching closure. /// You can also create predicates based on operators and key paths. /// - parameter matcher: The matching closure to use. - public init(matcher: @escaping (Target) -> Bool) { + public init(matcher: @escaping @Sendable (Target) -> Bool) { matches = matcher } } @@ -30,9 +30,11 @@ public extension Predicate { } } +public typealias _SendableKeyPath = any KeyPath & Sendable + /// Create a predicate for comparing a key path against a value. /// Usage example: `\.path == "somePath"`. -public func ==(lhs: KeyPath, rhs: V) -> Predicate { +public func ==(lhs: _SendableKeyPath, rhs: V) -> Predicate { Predicate { $0[keyPath: lhs] == rhs } } @@ -40,30 +42,30 @@ public func ==(lhs: KeyPath, rhs: V) -> Predicate { /// within a collection-based key path's value. /// Usage example: `\.tags ~= "someTag"`. public func ~=( - lhs: KeyPath, + lhs: _SendableKeyPath, rhs: V.Element -) -> Predicate where V.Element: Equatable { +) -> Predicate where V.Element: Equatable & Sendable { Predicate { $0[keyPath: lhs].contains(rhs) } } /// Create a predicate that matches against `false` values for a given /// `Bool` key path. /// Usage example: `!\.isExplicit` -public prefix func !(rhs: KeyPath) -> Predicate { +public prefix func !(rhs: _SendableKeyPath) -> Predicate { rhs == false } /// Create a predicate that matches when a key path's value is /// higher than a given value. /// Usage example: `\.metadata.intValue > 3`. -public func >(lhs: KeyPath, rhs: V) -> Predicate { +public func >(lhs: _SendableKeyPath, rhs: V) -> Predicate { Predicate { $0[keyPath: lhs] > rhs } } /// Create a predicate that matches when a key path's value is /// lower than a given value. /// Usage example: `\.metadata.intValue < 3`. -public func <(lhs: KeyPath, rhs: V) -> Predicate { +public func <(lhs: _SendableKeyPath, rhs: V) -> Predicate { Predicate { $0[keyPath: lhs] < rhs } } diff --git a/Sources/Publish/API/PublishedWebsite.swift b/Sources/Publish/API/PublishedWebsite.swift index 8baa54b7..ec911ccf 100644 --- a/Sources/Publish/API/PublishedWebsite.swift +++ b/Sources/Publish/API/PublishedWebsite.swift @@ -9,7 +9,7 @@ import Foundation /// Type representing a fully published website. An instance of this type /// is returned from every call to `Website.publish()`, and can be used /// to implement additional tooling on top of Publish. -public struct PublishedWebsite { +public struct PublishedWebsite: Sendable { /// The main website index that was published. public let index: Index /// The sections that were published. diff --git a/Sources/Publish/API/PublishingContext.swift b/Sources/Publish/API/PublishingContext.swift index 4dd14caa..1a6a84ad 100644 --- a/Sources/Publish/API/PublishingContext.swift +++ b/Sources/Publish/API/PublishingContext.swift @@ -5,54 +5,96 @@ */ import Foundation -import Ink import Plot import Files import Codextended +import MarkdownParser +import Synchronization /// Type that represents the context in which a website is being published. /// It can be used to manipulate the state of the website in various ways, /// including mutating and adding new content, creating new files and folders, /// and so on. Each `PublishingStep` gets access to the current context. -public struct PublishingContext { +public final class PublishingContext: Sendable { /// The website that this context is for. public let site: Site /// The Markdown parser that this publishing session is using. You can /// add modifiers to it to customize how each Markdown string is rendered. - public var markdownParser = MarkdownParser() + public var markdownParser: MarkdownParser { + state.withLock(\.markdownParser) + } /// The date formatter that this publishing session is using when parsing /// dates from Markdown files. - public var dateFormatter: DateFormatter - /// A representation of the website's main index page. - public var index = Index() - /// The sections that the website contains. - public var sections = SectionMap() { didSet { tagCache.tags = nil } } - /// The free-form pages that the website contains. - public private(set) var pages = [Path : Page]() + public let dateFormatter: DateFormatter + + /// Mutable State in PublishingContext + public struct State { + public var markdownParser: MarkdownParser = .init() + + /// A representation of the website's main index page. + public var index = Index() + /// The sections that the website contains. + public var sections = SectionMap() { + didSet { tagCache = nil } + } + + /// The free-form pages that the website contains. + public fileprivate(set) var pages = [Path : Page]() + + /// Any date when the website was last generated. + public fileprivate(set) var lastGenerationDate: Date? + + fileprivate var tagCache: Set? + fileprivate var stepName: String + } + + public var index: Index { state.withLock(\.index) } + + public var sections: SectionMap { state.withLock(\.sections) } + + public var pages: [Path: Page] { state.withLock(\.pages) } + + public var lastGenerationDate: Date? { state.withLock(\.lastGenerationDate) } + /// A set containing all tags that are currently being used website-wide. - public var allTags: Set { tagCache.tags ?? gatherAllTags() } - /// Any date when the website was last generated. - public private(set) var lastGenerationDate: Date? + public var allTags: Set { state.withLock(\.tagCache) ?? gatherAllTags() } + + public let state: Mutex private let folders: Folder.Group - private var tagCache = TagCache() - private var stepName: String internal init(site: Site, folders: Folder.Group, - firstStepName: String) { + firstStepName: String, + dateFormat: String = "yyyy-MM-dd HH:mm" + ) { self.site = site self.folders = folders - self.stepName = firstStepName + self.state = .init(.init(stepName: firstStepName)) let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" + dateFormatter.dateFormat = dateFormat dateFormatter.timeZone = .current self.dateFormatter = dateFormatter } } public extension PublishingContext { + func addModifier( + for target: Modifier.Target, + modifier: @escaping Modifier.Closure, + file: StaticString = #file, + line: Int = #line + ) { + state.withLock { + $0.markdownParser.addModifier(for: target, modifier: modifier, file: file, line: line) + } + } + + func index(content: Content) { + state.withLock { $0.index.content = content } + } + /// Retrieve a folder at a given path, starting from the website's root folder. /// - parameter path: The path to retrieve a folder for. /// - throws: An error in case the folder couldn't be found. @@ -182,7 +224,7 @@ public extension PublishingContext { /// - parameter name: The name of the cache file to return. /// - throws: An error in case a new file couldn't be created. func cacheFile(named name: String) throws -> File { - let folderName = stepName.normalized() + let folderName = state.withLock(\.stepName).normalized() let folder = try folders.caches.createSubfolderIfNeeded(withName: folderName) return try folder.createFileIfNeeded(withName: name.normalized()) } @@ -191,10 +233,10 @@ public extension PublishingContext { /// - parameter sortingKeyPath: The key path to sort the items by. /// - parameter order: The order to use when sorting the items. func allItems( - sortedBy sortingKeyPath: KeyPath, T>, + sortedBy sortingKeyPath: KeyPath, T> & Sendable, order: SortOrder = .ascending ) -> [Item] { - let items = sections.flatMap { $0.items } + let items = state.withLock { $0.sections.flatMap { $0.items } } return items.sorted( by: order.makeSorter(forKeyPath: sortingKeyPath) @@ -204,7 +246,14 @@ public extension PublishingContext { /// Return all items that were tagged with a given tag. /// - parameter tag: The tag to return all items for. func items(taggedWith tag: Tag) -> [Item] { - sections.flatMap { $0.items(taggedWith: tag) } + state.withLock { + let sections = $0.sections + let items = sections.flatMap { + $0.items(taggedWith: tag) + } + + return items + } } /// Return all items that were tagged with a given tag, sorted by @@ -214,7 +263,7 @@ public extension PublishingContext { /// - parameter order: The order to use when sorting the items. func items( taggedWith tag: Tag, - sortedBy sortingKeyPath: KeyPath, T>, + sortedBy sortingKeyPath: KeyPath, T> & Sendable, order: SortOrder = .ascending ) -> [Item] { items(taggedWith: tag).sorted( @@ -224,22 +273,72 @@ public extension PublishingContext { /// Add an item to the website programmatically. /// - parameter item: The item to add. - mutating func addItem(_ item: Item) { - sections[item.sectionID].addItem(item) + func addItem(_ item: Item) { + state.withLock { $0.sections[item.sectionID].addItem(item) } } + /// Add an item to this section. + /// - parameter path: The relative path to add an item at. + /// - parameter metadata: The item's site-specific metadata. + /// - parameter configure: A closure used to configure the new item. + func addItem( + to sectionId: Site.SectionID, + at path: Path, + withMetadata metadata: Site.ItemMetadata, + configure: (inout Item) throws -> Void + ) rethrows { + try state.withLock { + try $0.sections[sectionId].addItem(at: path, withMetadata: metadata, configure: configure) + } + } + + /// Add a page to the website programmatically. /// - parameter page: The page to add. - mutating func addPage(_ page: Page) { - pages[page.path] = page + func addPage(_ page: Page) { + state.withLock { + $0.pages[page.path] = page + } + } + + /// Mutate a section + /// - parameter id: The section that will be mutated + /// - parameter mutations: The mutations to apply to the section + func mutateSection(id: Site.SectionID, using mutations: Mutations>) rethrows { + try state.withLock { + try mutations(&$0.sections[id]) + } + } + /// Mutate an item at a given path within a section. + /// - parameter path: The relative path of the item to mutate. + /// - parameter section: The section that the item belongs to. + /// - parameter mutations: The mutations to apply to the item. + func mutateItem(at path: Path, in section: Site.SectionID, using mutations: Mutations>) throws { + try state.withLock { + try $0.sections[section].mutateItem(at: path, using: mutations) + } } /// Mutate all of the website's sections using a closure. /// - parameter mutations: The mutations to apply to each section. - mutating func mutateAllSections(using mutations: Mutations>) rethrows { + func mutateAllSections(using mutations: Mutations>) rethrows { + try state.withLock { + for id in $0.sections.ids { + try mutations(&$0.sections[id]) + } + } + } + /// Mutate all of the website's sections using an asynchronous closure. + /// - parameter mutations: The mutations to apply to each section. + func mutateAllSections(using mutations: AsyncMutations>) async rethrows { + // TODO: Probably want to do something to ensure that `sections` hasn't been mutated. + var sections = state.withLock(\.sections) + for id in sections.ids { - try mutations(§ions[id]) + try await mutations(§ions[id]) } + + state.withLock { $0.sections = sections } } /// Mutate one of the website's existing pages. @@ -248,40 +347,78 @@ public extension PublishingContext { /// - parameter mutations: The mutations to apply to the page. /// - throws: An error in case the page couldn't be found, or /// if the mutation close itself threw an error. - mutating func mutatePage(at path: Path, - matching predicate: Predicate = .any, - using mutations: Mutations) throws { - guard var page = pages[path] else { - throw ContentError(path: path, reason: .pageNotFound) + func mutatePage(at path: Path, + matching predicate: Predicate = .any, + using mutations: Mutations) throws { + try state.withLock { + guard var page = $0.pages[path] else { + throw ContentError(path: path, reason: .pageNotFound) + } + + guard predicate.matches(page) else { + return + } + + do { + try mutations(&page) + $0.pages[page.path] = page + + if page.path != path { + $0.pages[path] = nil + } + } catch { + throw ContentError( + path: page.path, + reason: .pageMutationFailed(error) + ) + } } + } - guard predicate.matches(page) else { - return + /// Remove all items matching a predicate, optionally within a specific section. + /// - parameter section: Any specific section to remove all items within. + /// - parameter predicate: Any predicate to filter the items using. + func removeAllItems( + in section: Site.SectionID? = nil, + matching predicate: Predicate> = .any + ) { + if let section { + state.withLock { state in + state.sections[section].removeItems(matching: predicate) + } + } else { + mutateAllSections { $0.removeItems(matching: predicate) } } + } - do { - try mutations(&page) - pages[page.path] = page + /// Sort all items, using a keypath, optionally within a specific section. + /// - parameter section: Any specific section to sort all items within. + /// - parameter keyPath: The key path to use when sorting. + /// - parameter order: The order to use when sorting. + func sortItems( + in section: Site.SectionID? = nil, + by keyPath: _SendableKeyPath, T>, + order: SortOrder = .ascending + ) { + let sorter = order.makeSorter(forKeyPath: keyPath) - if page.path != path { - pages[path] = nil + if let section { + state.withLock { state in + state.sections[section].sortItems(by: sorter) } - } catch { - throw ContentError( - path: page.path, - reason: .pageMutationFailed(error) - ) + } else { + mutateAllSections { $0.sortItems(by: sorter) } } } } internal extension PublishingContext { - mutating func generationWillBegin() { + func generationWillBegin() { try? updateLastGenerationDate() } - mutating func prepareForStep(named name: String) { - stepName = name + func prepareForStep(named name: String) { + state.withLock { $0.stepName = name } } func makeMarkdownContentFactory() -> MarkdownContentFactory { @@ -311,19 +448,17 @@ internal extension PublishingContext { } private extension PublishingContext { - final class TagCache { - var tags: Set? - } - - mutating func updateLastGenerationDate() throws { + func updateLastGenerationDate() throws { let fileName = "lastGenerationDate" let newString = String(Date().timeIntervalSince1970) if let file = try? folders.internal.file(named: fileName) { let oldInterval = try TimeInterval(file.readAsString()) - lastGenerationDate = oldInterval.map { - Date(timeIntervalSince1970: $0) + state.withLock { + $0.lastGenerationDate = oldInterval.map { + Date(timeIntervalSince1970: $0) + } } try file.write(newString) @@ -334,14 +469,16 @@ private extension PublishingContext { } func gatherAllTags() -> Set { - var tags = Set() + state.withLock { + var tags = Set() - for section in sections { - tags.formUnion(section.allTags) - } + for section in $0.sections { + tags.formUnion(section.allTags) + } - tagCache.tags = tags - return tags + $0.tagCache = tags + return tags + } } func copyLocationToOutput( diff --git a/Sources/Publish/API/PublishingError.swift b/Sources/Publish/API/PublishingError.swift index dfe87b68..c9eb992b 100644 --- a/Sources/Publish/API/PublishingError.swift +++ b/Sources/Publish/API/PublishingError.swift @@ -7,7 +7,7 @@ import Foundation /// Error type thrown as part of the website publishing process. -public struct PublishingError: Equatable { +public struct PublishingError: Equatable, Sendable { /// Any step that the error was encountered during. public var stepName: String? /// Any path that the error was encountered at. diff --git a/Sources/Publish/API/PublishingStep.swift b/Sources/Publish/API/PublishingStep.swift index 63e8a7b0..433b1e12 100644 --- a/Sources/Publish/API/PublishingStep.swift +++ b/Sources/Publish/API/PublishingStep.swift @@ -13,9 +13,9 @@ import Plot /// be combined into groups, and conditionally executed. Publish ships with many /// built-in steps, and new ones can easily be defined using `step(named:body:)`. /// Steps are added when calling `Website.publish`. -public struct PublishingStep { +public struct PublishingStep: Sendable { /// Closure type used to define the main body of a publishing step. - public typealias Closure = (inout PublishingContext) async throws -> Void + public typealias Closure = @Sendable (inout PublishingContext) async throws -> Void internal let kind: Kind internal let body: Body @@ -98,7 +98,7 @@ public extension PublishingStep { /// Add a sequence of items to website programmatically. /// - parameter sequence: The items to add. - static func addItems( + static func addItems( in sequence: S ) -> Self where S.Element == Item { step(named: "Add items in sequence") { context in @@ -116,7 +116,7 @@ public extension PublishingStep { /// Add a sequence of pages to website programmatically. /// - parameter sequence: The pages to add. - static func addPages( + static func addPages( in sequence: S ) -> Self where S.Element == Page { step(named: "Add pages in sequence") { context in @@ -145,13 +145,7 @@ public extension PublishingStep { let nameSuffix = section.map { " in '\($0)'" } ?? "" return step(named: "Remove items" + nameSuffix) { context in - if let section = section { - context.sections[section].removeItems(matching: predicate) - } else { - for section in context.sections.ids { - context.sections[section].removeItems(matching: predicate) - } - } + context.removeAllItems(in: section, matching: predicate) } } @@ -191,9 +185,11 @@ public extension PublishingStep { } return step(named: stepName) { context in - for section in sections { - try await context.sections[section].replaceItems( - with: context.sections[section].items.concurrentMap { item in + try await context.mutateAllSections { section in + guard sections.contains(section.id) else { return } + + try await section.replaceItems( + with: section.items.concurrentMap { item in guard predicate.matches(item) else { return item } @@ -224,7 +220,7 @@ public extension PublishingStep { using mutations: @escaping Mutations> ) -> Self { step(named: "Mutate item at '\(path)' in \(section)") { context in - try context.sections[section].mutateItem(at: path, using: mutations) + try context.mutateItem(at: path, in: section, using: mutations) } } @@ -264,21 +260,17 @@ public extension PublishingStep { /// - parameter order: The order to use when sorting. static func sortItems( in section: Site.SectionID? = nil, - by keyPath: KeyPath, T>, + by keyPath: KeyPath, T> & Sendable, order: SortOrder = .ascending ) -> Self { let nameSuffix = section.map { " in '\($0)'" } ?? "" return step(named: "Sort items" + nameSuffix) { context in - let sorter = order.makeSorter(forKeyPath: keyPath) - - if let section = section { - context.sections[section].sortItems(by: sorter) - } else { - for section in context.sections { - context.sections[section.id].sortItems(by: sorter) - } - } + context.sortItems( + in: section, + by: keyPath, + order: order + ) } } } diff --git a/Sources/Publish/API/Section.swift b/Sources/Publish/API/Section.swift index 00d3039b..5ef2e25b 100644 --- a/Sources/Publish/API/Section.swift +++ b/Sources/Publish/API/Section.swift @@ -10,7 +10,7 @@ import Foundation /// its `SectionID` type. Each section can contain content of its own, /// as well as a list of items. To modify a given section, access it /// through the `sections` property on the current `PublishingContext`. -public struct Section: Location { +public struct Section: Location, Sendable { /// The section's ID, as defined by its `Website` implementation. public let id: Site.SectionID /// The items contained within the section. diff --git a/Sources/Publish/API/SectionMap.swift b/Sources/Publish/API/SectionMap.swift index a31b2f15..9d234524 100644 --- a/Sources/Publish/API/SectionMap.swift +++ b/Sources/Publish/API/SectionMap.swift @@ -8,7 +8,7 @@ import Foundation /// A map type containing all sections within a given website. /// You access an instance of this type through the current `PublishingContext`. -public struct SectionMap { +public struct SectionMap: Sendable { /// The IDs of all the sections contained within this map, in the order /// they were defined within the site's `SectionID` enum. public var ids: Site.SectionID.AllCases { Site.SectionID.allCases } diff --git a/Sources/Publish/API/Server.swift b/Sources/Publish/API/Server.swift new file mode 100644 index 00000000..28652147 --- /dev/null +++ b/Sources/Publish/API/Server.swift @@ -0,0 +1,143 @@ +import NIO +import Files +import NIOCore +import NIOHTTP1 +import NIOFileSystem +import UniformTypeIdentifiers + +extension Website { + public func serve( + at path: Path? = nil, + host: String = "0.0.0.0", + port: Int = 8080, + file: StaticString = #filePath + ) async throws { + let root = try FilePath(resolveRootFolder(withExplicitPath: path, file: file).path).appending("Output") + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + let channel = try await ServerBootstrap(group: eventLoopGroup) + .serverChannelOption(.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .bind(host: host, port: port) { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.configureHTTPServerPipeline(withErrorHandling: true) + + return try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: .init( + inboundType: HTTPServerRequestPart.self, + outboundType: HTTPServerResponsePart.self + ) + ) + } + } + + print("🚀 Server running at \(host):\(port)") + + try await withThrowingDiscardingTaskGroup { group in + try await channel.executeThenClose { inbound in + for try await connectionChannel in inbound { + group.addTask { + try await handleConnection(channel: connectionChannel, root: root) + } + } + } + } + + try await eventLoopGroup.shutdownGracefully() + } + + fileprivate func resolveRootFolder(withExplicitPath path: Path?, file: StaticString) throws -> Folder { + if let path = path { + return try Folder(path: path.string) + } + + return try File(path: "\(file)").resolveSwiftPackageFolder() + } + + fileprivate func handleConnection(channel: NIOAsyncChannel, root: FilePath) async throws { + do { + try await channel.executeThenClose { inbound, outbound in + var head: HTTPRequestHead! + var body = ByteBuffer() + + for try await data in inbound { + switch data { + case .head(let requestHead): + head = requestHead + case .body(var requestBody): + body.writeBuffer(&requestBody) + case .end(_): + try await handle(request: head, body: body, output: outbound, root: root) + } + } + + outbound.finish() + } + } + } + + fileprivate func respond404(_ output: NIOAsyncChannelOutboundWriter, _ request: HTTPRequestHead) async throws { + try await output.write(.head(.init(version: request.version, status: .notFound, headers: .init([("Content-Size", "0")])))) + try await output.write(.end(nil)) + } + + fileprivate func handle(request: HTTPRequestHead, body: ByteBuffer, output: NIOAsyncChannelOutboundWriter, root: FilePath) async throws { + let filePath = root + .appending(FilePath(request.uri.removingPercentEncoding ?? "/").components) + .lexicallyNormalized() + + guard filePath.starts(with: root) else { + try await respond404(output, request) + return + } + + guard let info = try await FileSystem.shared.info(forFileAt: filePath), info.type == .regular || info.type == .directory else { + try await respond404(output, request) + return + } + do { + let file: FilePath + let fileSize: Int64 + if info.type == .directory { + file = filePath.appending("index.html") + fileSize = try await FileSystem.shared.info(forFileAt: file).map(\.size) ?? 0 + } else { + file = filePath + fileSize = info.size + } + + let contentType = file.extension.flatMap { UTType(filenameExtension: $0) } ?? .plainText + let fileHandle = try await FileSystem.shared.openFile(forReadingAt: file) + + do { + + let responseHead = HTTPResponseHead( + version: request.version, + status: .ok, + headers: .init( + [ + ("Content-Length", "\(fileSize)"), + ("Content-Type", contentType.preferredMIMEType ?? "text/plain") + ] + ) + ) + + try await output.write(.head(responseHead)) + for try await chunk in fileHandle.readChunks() { + try await output.write(.body(.byteBuffer(chunk))) + } + + try await output.write(.end(nil)) + try await fileHandle.close() + + } catch { + try await fileHandle.close() + throw error + } + } catch { + print("❌ \(error)") + } + } + +} diff --git a/Sources/Publish/API/SortOrder.swift b/Sources/Publish/API/SortOrder.swift index bebd5cd4..8aa73288 100644 --- a/Sources/Publish/API/SortOrder.swift +++ b/Sources/Publish/API/SortOrder.swift @@ -6,7 +6,7 @@ /// Enum describing various orders that can be used when /// performing sorting operations. -public enum SortOrder { +public enum SortOrder: Sendable { /// Sort the collection in ascending order. case ascending /// Sort the collection in descending order. @@ -15,8 +15,8 @@ public enum SortOrder { internal extension SortOrder { func makeSorter( - forKeyPath keyPath: KeyPath - ) -> (T, T) -> Bool { + forKeyPath keyPath: _SendableKeyPath + ) -> @Sendable (T, T) -> Bool { switch self { case .ascending: return { diff --git a/Sources/Publish/API/StringWrapper.swift b/Sources/Publish/API/StringWrapper.swift index d61726cb..e725d835 100644 --- a/Sources/Publish/API/StringWrapper.swift +++ b/Sources/Publish/API/StringWrapper.swift @@ -11,7 +11,8 @@ public protocol StringWrapper: CustomStringConvertible, ExpressibleByStringInterpolation, Codable, Hashable, - Comparable { + Comparable, + Sendable { /// The underlying string value backing this instance. var string: String { get } /// Initialize a new instance with an underlying string value. diff --git a/Sources/Publish/API/Tag.swift b/Sources/Publish/API/Tag.swift index 0c8cffa9..484eeb76 100644 --- a/Sources/Publish/API/Tag.swift +++ b/Sources/Publish/API/Tag.swift @@ -8,7 +8,7 @@ import Foundation /// Type used to represent a content tag. Items may be tagged, and then /// retrieved based on any tag that they were associated with. -public struct Tag: StringWrapper { +public struct Tag: StringWrapper, Sendable { public var string: String public init(_ string: String) { diff --git a/Sources/Publish/API/TagDetailsPage.swift b/Sources/Publish/API/TagDetailsPage.swift index 4732dbee..94c27e14 100644 --- a/Sources/Publish/API/TagDetailsPage.swift +++ b/Sources/Publish/API/TagDetailsPage.swift @@ -7,7 +7,7 @@ import Foundation /// A representation of a page that contains details about a given tag. -public struct TagDetailsPage: Location { +public struct TagDetailsPage: Location, Sendable { /// The tag that the details page is for. public var tag: Tag public let path: Path diff --git a/Sources/Publish/API/TagHTMLConfiguration.swift b/Sources/Publish/API/TagHTMLConfiguration.swift index 80742d3a..b777246b 100644 --- a/Sources/Publish/API/TagHTMLConfiguration.swift +++ b/Sources/Publish/API/TagHTMLConfiguration.swift @@ -7,13 +7,13 @@ /// Configuration type used to customize how a website's /// tag page gets rendered. To use a default implementation, /// use `TagHTMLConfiguration.default`. -public struct TagHTMLConfiguration { +public struct TagHTMLConfiguration: Sendable { /// The based path of all of the site's tag HTML. public var basePath: Path /// Any content that should be added to the site's tag list page. public var listContent: Content? /// Any closure used to resolve content for each tag details page. - public var detailsContentResolver: (Tag) -> Content? + public var detailsContentResolver: @Sendable (Tag) -> Content? /// Initialize a new configuration instance. /// - Parameter basePath: The based path of all of the site's tag HTML. @@ -23,7 +23,7 @@ public struct TagHTMLConfiguration { public init( basePath: Path = .defaultForTagHTML, listContent: Content? = nil, - detailsContentResolver: @escaping (Tag) -> Content? = { _ in nil } + detailsContentResolver: @escaping @Sendable (Tag) -> Content? = { _ in nil } ) { self.basePath = basePath self.listContent = listContent diff --git a/Sources/Publish/API/TagListPage.swift b/Sources/Publish/API/TagListPage.swift index 2ad55087..ed2a35a2 100644 --- a/Sources/Publish/API/TagListPage.swift +++ b/Sources/Publish/API/TagListPage.swift @@ -5,7 +5,7 @@ */ /// A representation of the page that contains all of a website's tags. -public struct TagListPage: Location { +public struct TagListPage: Location, Sendable { /// All of the tags used within the website. public var tags: Set public let path: Path diff --git a/Sources/Publish/API/Theme+Foundation.swift b/Sources/Publish/API/Theme+Foundation.swift index 39cdcbb4..cd98439a 100644 --- a/Sources/Publish/API/Theme+Foundation.swift +++ b/Sources/Publish/API/Theme+Foundation.swift @@ -23,9 +23,9 @@ private struct FoundationHTMLFactory: HTMLFactory { HTML( .lang(context.site.language), .head(for: index, on: context.site), - .body { + .body { [index] in SiteHeader(context: context, selectedSectionID: nil) - Wrapper { + Wrapper { [index] in H1(index.title) Paragraph(context.site.description) .class("description") diff --git a/Sources/Publish/API/Theme.swift b/Sources/Publish/API/Theme.swift index f083976f..98b618fa 100644 --- a/Sources/Publish/API/Theme.swift +++ b/Sources/Publish/API/Theme.swift @@ -10,13 +10,13 @@ import Plot /// When implementing reusable themes that are vended as frameworks or APIs, /// it's recommended to create them using static factory methods, just like /// how the built-in `foundation` theme is implemented. -public struct Theme { - internal let makeIndexHTML: (Index, PublishingContext) throws -> HTML - internal let makeSectionHTML: (Section, PublishingContext) throws -> HTML - internal let makeItemHTML: (Item, PublishingContext) throws -> HTML - internal let makePageHTML: (Page, PublishingContext) throws -> HTML - internal let makeTagListHTML: (TagListPage, PublishingContext) throws -> HTML? - internal let makeTagDetailsHTML: (TagDetailsPage, PublishingContext) throws -> HTML? +public struct Theme: Sendable { + internal let makeIndexHTML: @Sendable (Index, PublishingContext) throws -> HTML + internal let makeSectionHTML: @Sendable (Section, PublishingContext) throws -> HTML + internal let makeItemHTML: @Sendable (Item, PublishingContext) throws -> HTML + internal let makePageHTML: @Sendable (Page, PublishingContext) throws -> HTML + internal let makeTagListHTML: @Sendable (TagListPage, PublishingContext) throws -> HTML? + internal let makeTagDetailsHTML: @Sendable (TagDetailsPage, PublishingContext) throws -> HTML? internal let resourcePaths: Set internal let creationPath: Path @@ -30,7 +30,7 @@ public struct Theme { public init( htmlFactory factory: T, resourcePaths resources: Set = [], - file: StaticString = #file + file: StaticString = #filePath ) where T.Site == Site { makeIndexHTML = factory.makeIndexHTML makeSectionHTML = factory.makeSectionHTML diff --git a/Sources/Publish/API/Video.swift b/Sources/Publish/API/Video.swift index 84c41c11..552f038c 100644 --- a/Sources/Publish/API/Video.swift +++ b/Sources/Publish/API/Video.swift @@ -9,7 +9,7 @@ import Plot /// A representation of a location's video data. Can be used to implement /// inline video players using the `videoPlayer` Plot component. -public enum Video: Hashable { +public enum Video: Hashable, Sendable { /// A self-hosted video located at a given URL. case hosted(url: URL, format: HTMLVideoFormat = .mp4) /// A YouTube video with a given ID. diff --git a/Sources/Publish/API/Website.swift b/Sources/Publish/API/Website.swift index fd4c5b8c..803119a7 100644 --- a/Sources/Publish/API/Website.swift +++ b/Sources/Publish/API/Website.swift @@ -9,9 +9,9 @@ import Plot import Dispatch /// Protocol that all `Website.SectionID` implementations must conform to. -public protocol WebsiteSectionID: Decodable, Hashable, CaseIterable, RawRepresentable where RawValue == String {} +public protocol WebsiteSectionID: Decodable, Hashable, CaseIterable, RawRepresentable, Sendable where RawValue == String, AllCases: Sendable {} /// Protocol that all `Website.ItemMetadata` implementations must conform to. -public typealias WebsiteItemMetadata = Decodable & Hashable +public typealias WebsiteItemMetadata = Decodable & Hashable & Sendable /// Protocol used to define a Publish-based website. /// You conform to this protocol using a custom type, which is then used to @@ -20,7 +20,7 @@ public typealias WebsiteItemMetadata = Decodable & Hashable /// up of `PublishingStep` values, which is constructed using the `publish` method. /// To generate the necessary bootstrapping for conforming to this protocol, use /// the `publish new` command line tool. -public protocol Website { +public protocol Website: Sendable { /// The enum type used to represent the website's section IDs. associatedtype SectionID: WebsiteSectionID /// The type that defines any custom metadata for the website. @@ -75,6 +75,7 @@ public extension Website { deployedUsing deploymentMethod: DeploymentMethod? = nil, additionalSteps: [PublishingStep] = [], plugins: [Plugin] = [], + deploy: Bool, file: StaticString = #file) throws -> PublishedWebsite { try publish( at: path, @@ -94,6 +95,7 @@ public extension Website { .generateSiteMap(indentedBy: indentation), .unwrap(deploymentMethod, PublishingStep.deploy) ], + deploy: deploy, file: file ) } @@ -106,10 +108,12 @@ public extension Website { @discardableResult func publish(at path: Path? = nil, using steps: [PublishingStep], + deploy: Bool, file: StaticString = #file) throws -> PublishedWebsite { let pipeline = PublishingPipeline( steps: steps, - originFilePath: Path("\(file)") + originFilePath: Path("\(file)"), + deploy: deploy ) let semaphore = DispatchSemaphore(value: 0) @@ -153,6 +157,7 @@ public extension Website { deployedUsing deploymentMethod: DeploymentMethod? = nil, additionalSteps: [PublishingStep] = [], plugins: [Plugin] = [], + deploy: Bool, file: StaticString = #file) async throws -> PublishedWebsite { try await publish( at: path, @@ -172,6 +177,7 @@ public extension Website { .generateSiteMap(indentedBy: indentation), .unwrap(deploymentMethod, PublishingStep.deploy) ], + deploy: deploy, file: file ) } @@ -184,10 +190,12 @@ public extension Website { @discardableResult func publish(at path: Path? = nil, using steps: [PublishingStep], + deploy: Bool, file: StaticString = #file) async throws -> PublishedWebsite { let pipeline = PublishingPipeline( steps: steps, - originFilePath: Path("\(file)") + originFilePath: Path("\(file)"), + deploy: deploy ) return try await pipeline.execute(for: self, at: path) } diff --git a/Sources/Publish/Internal/Folder+Group.swift b/Sources/Publish/Internal/Folder+Group.swift index 11f1b6b4..37e2e1eb 100644 --- a/Sources/Publish/Internal/Folder+Group.swift +++ b/Sources/Publish/Internal/Folder+Group.swift @@ -7,7 +7,7 @@ import Files internal extension Folder { - struct Group { + struct Group: Sendable { let root: Folder let output: Folder let `internal`: Folder diff --git a/Sources/Publish/Internal/HTMLGenerator.swift b/Sources/Publish/Internal/HTMLGenerator.swift index 411bf428..74d46419 100644 --- a/Sources/Publish/Internal/HTMLGenerator.swift +++ b/Sources/Publish/Internal/HTMLGenerator.swift @@ -15,16 +15,26 @@ internal struct HTMLGenerator { let context: PublishingContext func generate() async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { try await copyThemeResources() } - group.addTask { try generateIndexHTML() } - group.addTask { try await generateSectionHTML() } - group.addTask { try await generatePageHTML() } - group.addTask { try await generateTagHTMLIfNeeded() } - - // Throw any errors generated by the above set of operations: - for try await _ in group {} - } +// try await withThrowingTaskGroup(of: Void.self) { group in +// group.addTask { + try await copyThemeResources() +// } +// group.addTask { + try generateIndexHTML() +// } +// group.addTask { + try await generateSectionHTML() +// } +// group.addTask { + try await generatePageHTML() +// } +// group.addTask { + try await generateTagHTMLIfNeeded() +// } +// +// // Throw any errors generated by the above set of operations: +// for try await _ in group {} +// } } } @@ -65,7 +75,7 @@ private extension HTMLGenerator { using: theme.makeSectionHTML, fileMode: .foldersAndIndexFiles ) - + try await section.items.concurrentForEach { item in try outputHTML( for: item, diff --git a/Sources/Publish/Internal/MarkdownContentFactory.swift b/Sources/Publish/Internal/MarkdownContentFactory.swift index 74dfd60e..2e1a0dfa 100644 --- a/Sources/Publish/Internal/MarkdownContentFactory.swift +++ b/Sources/Publish/Internal/MarkdownContentFactory.swift @@ -5,9 +5,9 @@ */ import Foundation -import Ink import Files import Codextended +import MarkdownParser internal struct MarkdownContentFactory { let parser: MarkdownParser @@ -50,14 +50,14 @@ internal struct MarkdownContentFactory { } private extension MarkdownContentFactory { - func makeMetadataDecoder(for markdown: Ink.Markdown) -> MarkdownMetadataDecoder { + func makeMetadataDecoder(for markdown: MarkdownDocument) -> MarkdownMetadataDecoder { MarkdownMetadataDecoder( metadata: markdown.metadata, dateFormatter: dateFormatter ) } - func makeContent(fromMarkdown markdown: Ink.Markdown, + func makeContent(fromMarkdown markdown: MarkdownDocument, file: File, decoder: MarkdownMetadataDecoder) throws -> Content { let title = try decoder.decodeIfPresent("title", as: String.self) @@ -71,7 +71,7 @@ private extension MarkdownContentFactory { return Content( title: title ?? markdown.title ?? file.nameExcludingExtension, description: description ?? "", - body: Content.Body(html: markdown.html), + body: Content.Body(node: markdown.body), date: date, lastModified: lastModified, imagePath: imagePath, diff --git a/Sources/Publish/Internal/MarkdownFileHandler.swift b/Sources/Publish/Internal/MarkdownFileHandler.swift index ffeaaaa3..393d95aa 100644 --- a/Sources/Publish/Internal/MarkdownFileHandler.swift +++ b/Sources/Publish/Internal/MarkdownFileHandler.swift @@ -16,7 +16,7 @@ internal struct MarkdownFileHandler { if let indexFile = try? folder.file(named: "index.md") { do { - context.index.content = try factory.makeContent(fromFile: indexFile) + context.index(content: try factory.makeContent(fromFile: indexFile)) } catch { throw wrap(error, forPath: "\(folder.path)index.md") } @@ -74,7 +74,7 @@ internal struct MarkdownFileHandler { } case .section(let id, let content, let items): if let content = content { - context.sections[id].content = content + context.mutateSection(id: id) { $0.content = content } } for item in items { @@ -108,7 +108,8 @@ private extension MarkdownFileHandler { parentPath: Path, factory: MarkdownContentFactory ) async throws -> [Page] { - let pages: [Page] = try await folder.files.concurrentCompactMap { file in + let pages: [Page] = + try await folder.files.concurrentCompactMap { file in guard file.isMarkdown else { return nil } if file.nameExcludingExtension == "index", !recursively { diff --git a/Sources/Publish/Internal/PublishingPipeline.swift b/Sources/Publish/Internal/PublishingPipeline.swift index 70661927..998dd394 100644 --- a/Sources/Publish/Internal/PublishingPipeline.swift +++ b/Sources/Publish/Internal/PublishingPipeline.swift @@ -13,25 +13,30 @@ import Cocoa internal struct PublishingPipeline { let steps: [PublishingStep] let originFilePath: Path + fileprivate let kind: Step.Kind + + init(steps: [PublishingStep], originFilePath: Path, deploy: Bool) { + self.steps = steps + self.originFilePath = originFilePath + self.kind = deploy ? .deployment : .generation + } } extension PublishingPipeline { func execute(for site: Site, at path: Path?) async throws -> PublishedWebsite { - let stepKind = resolveStepKind() - let folders = try setUpFolders( withExplicitRootPath: path, - shouldEmptyOutputFolder: stepKind == .generation + shouldEmptyOutputFolder: kind == .generation ) let steps = self.steps.flatMap { step in - runnableSteps(ofKind: stepKind, from: step) + runnableSteps(ofKind: kind, from: step) } guard let firstStep = steps.first else { throw PublishingError( infoMessage: """ - \(site.name) has no \(stepKind.rawValue) steps. + \(site.name) has no \(kind.rawValue) steps. """ ) } @@ -132,12 +137,6 @@ private extension PublishingPipeline { return try originFile.resolveSwiftPackageFolder() } - func resolveStepKind() -> Step.Kind { - let deploymentFlags: Set = ["--deploy", "-d"] - let shouldDeploy = CommandLine.arguments.contains(where: deploymentFlags.contains) - return shouldDeploy ? .deployment : .generation - } - func runnableSteps(ofKind kind: Step.Kind, from step: Step) -> [RunnableStep] { switch step.kind { case .system, kind: break diff --git a/Tests/MarkdownParserTests/CodeTests.swift b/Tests/MarkdownParserTests/CodeTests.swift new file mode 100644 index 00000000..81e51eb0 --- /dev/null +++ b/Tests/MarkdownParserTests/CodeTests.swift @@ -0,0 +1,87 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import XCTest +import Publish + +final class CodeTests: XCTestCase { + func testInlineCode() { + let html = MarkdownParser().html(from: "Hello `inline.code()`") + XCTAssertEqual(html, "

Hello inline.code()

") + } + + func testCodeBlockWithJustBackticks() { + let html = MarkdownParser().html(from: """ + ``` + code() + block() + ``` + """) + + XCTAssertEqual(html, "
code()\nblock()\n
") + } + + func testCodeBlockWithBackticksAndLabel() { + let html = MarkdownParser().html(from: """ + ```swift + code() + ``` + """) + + XCTAssertEqual(html, "
code()\n
") + } + + func testCodeBlockWithBackticksAndLabelNeedingTrimming() { + // there are 2 spaces after the swift label that need trimming too + let html = MarkdownParser().html(from: """ + ``` swift + code() + ``` + """) + + XCTAssertEqual(html, "
code()\n
") + } + + func testCodeBlockManyBackticks() { + // there are 2 spaces after the swift label that need trimming too + let html = MarkdownParser().html(from: """ + + ```````````````````````````````` foo + bar + ```````````````````````````````` + """) + + XCTAssertEqual(html, "
bar\n
") + } + + func testEncodingSpecialCharactersWithinCodeBlock() { + let html = MarkdownParser().html(from: """ + ```swift + Generic() && expression() + ``` + """) + + XCTAssertEqual(html, """ +
Generic<T>() && expression()\n
+ """) + } + + func testIgnoringFormattingWithinCodeBlock() { + let html = MarkdownParser().html(from: """ + ``` + # Not A Header + return View() + - Not a list + ``` + """) + + XCTAssertEqual(html, """ +
# Not A Header
+        return View()
+        - Not a list\n
+ """) + } +} diff --git a/Tests/MarkdownParserTests/HTMLTests.swift b/Tests/MarkdownParserTests/HTMLTests.swift new file mode 100644 index 00000000..ae374e4b --- /dev/null +++ b/Tests/MarkdownParserTests/HTMLTests.swift @@ -0,0 +1,116 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import XCTest +import Publish + +final class HTMLTests: XCTestCase { + func testTopLevelHTML() { + let html = MarkdownParser().html(from: """ + Hello + +
+ Whole wide +
+ + World + """) + + XCTAssertEqual(html, """ +

Hello

+ Whole wide +
+

World

+ """) + } + + func testNestedTopLevelHTML() { + let html = MarkdownParser().html(from: """ +
+
Hello
+
World
+
+ """) + + XCTAssertEqual(html, """ +
+
Hello
+
World
+
+ + """) + } + + func testTopLevelHTMLWithPreviousNewline() { + let html = MarkdownParser().html(from: "Text\n

Heading

") + XCTAssertEqual(html, "

Text

Heading

\n") + } + + func testIgnoringFormattingWithinTopLevelHTML() { + let html = MarkdownParser().html(from: "
_Hello_
") + XCTAssertEqual(html, "
_Hello_
\n") + } + + func testTextFormattingWithinInlineHTML() { + let html = MarkdownParser().html(from: "Hello _World_") + XCTAssertEqual(html, "

Hello World

") + } + + func testIgnoringListsWithinInlineHTML() { + let html = MarkdownParser().html(from: "

1. Hello

- World

") + XCTAssertEqual(html, "

1. Hello

- World

\n") + } + + func testInlineParagraphTagEndingCurrentParagraph() { + let html = MarkdownParser().html(from: "One

Two

Three") + XCTAssertEqual(html, "

One

Two

Three

") + } + + func testTopLevelSelfClosingHTMLElement() { + let html = MarkdownParser().html(from: """ + Hello + + + + World + """) + + XCTAssertEqual(html, "

Hello

\n

World

") + } + + func testInlineSelfClosingHTMLElement() { + let html = MarkdownParser().html(from: #"Hello World"#) + XCTAssertEqual(html, #"

Hello World

"#) + } + + func testTopLevelHTMLLineBreak() { + let html = MarkdownParser().html(from: """ + Hello +
+ World + """) + + XCTAssertEqual(html, "

Hello
World

") + } + + func testHTMLComment() { + let html = MarkdownParser().html(from: """ + Hello + + World + """) + + XCTAssertEqual(html, "

Hello

\n

World

") + } + + func testHTMLEntities() { + let html = MarkdownParser().html(from: """ + Hello & welcome to <Ink> + """) + + XCTAssertEqual(html, "

Hello & welcome to <Ink>

") + } +} diff --git a/Tests/MarkdownParserTests/HeadingTests.swift b/Tests/MarkdownParserTests/HeadingTests.swift new file mode 100644 index 00000000..70683b37 --- /dev/null +++ b/Tests/MarkdownParserTests/HeadingTests.swift @@ -0,0 +1,61 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import XCTest +import Publish + +final class HeadingTests: XCTestCase { + func testHeading() { + let html = MarkdownParser().html(from: "# Hello, world!") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testHeadingsSeparatedBySingleNewline() { + let html = MarkdownParser().html(from: "# Hello\n## World") + XCTAssertEqual(html, "

Hello

World

") + } + + func testHeadingsWithLeadingNumbers() { + let html = MarkdownParser().html(from: """ + # 1. First + ## 2. Second + ## 3. Third + ### 4. Forth + """) + + XCTAssertEqual(html, """ +

1. First

2. Second

3. Third

4. Forth

+ """) + } + + func testHeadingWithPreviousWhitespace() { + let html = MarkdownParser().html(from: "Text \n## Heading") + XCTAssertEqual(html, "

Text

Heading

") + } + + func testHeadingWithPreviousNewlineAndWhitespace() { + let html = MarkdownParser().html(from: "Hello\n \n## Heading\n\nWorld") + XCTAssertEqual(html, "

Hello

Heading

World

") + } + + func testInvalidHeaderLevel() { + let markdown = String(repeating: "#", count: 7) + let html = MarkdownParser().html(from: markdown) + XCTAssertEqual(html, "

\(markdown)

") + } + + func testRemovingTrailingMarkersFromHeading() { + let markdown = "# Heading #######" + let html = MarkdownParser().html(from: markdown) + XCTAssertEqual(html, "

Heading

") + } + + func testHeadingWithOnlyTrailingMarkers() { + let markdown = "# #######" + let html = MarkdownParser().html(from: markdown) + XCTAssertEqual(html, "

") + } +} diff --git a/Tests/MarkdownParserTests/HorizontalLineTests.swift b/Tests/MarkdownParserTests/HorizontalLineTests.swift new file mode 100644 index 00000000..a6ad66e0 --- /dev/null +++ b/Tests/MarkdownParserTests/HorizontalLineTests.swift @@ -0,0 +1,39 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import XCTest +import Publish + +final class HorizontalLineTests: XCTestCase { + func testHorizonalLineWithDashes() { + let html = MarkdownParser().html(from: """ + Hello + + --- + + World + """) + + XCTAssertEqual(html, "

Hello


World

") + } + + func testHorizontalLineWithDashesAtTheStartOfString() { + let html = MarkdownParser().html(from: "---\nHello") + XCTAssertEqual(html, "

Hello

") + } + + func testHorizontalLineWithAsterisks() { + let html = MarkdownParser().html(from: """ + Hello + + *** + + World + """) + + XCTAssertEqual(html, "

Hello


World

") + } +} diff --git a/Tests/MarkdownParserTests/ImageTests.swift b/Tests/MarkdownParserTests/ImageTests.swift new file mode 100644 index 00000000..69db0b24 --- /dev/null +++ b/Tests/MarkdownParserTests/ImageTests.swift @@ -0,0 +1,45 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import XCTest +import Publish + +final class ImageTests: XCTestCase { + func testImageWithURL() { + let html = MarkdownParser().html(from: "![](url)") + XCTAssertEqual(html, #"

"#) + } + + func testImageWithReference() { + let html = MarkdownParser().html(from: """ + ![][url] + + [url]: https://swiftbysundell.com + """) + + XCTAssertEqual(html, #"

"#) + } + + func testImageWithURLAndAltText() { + let html = MarkdownParser().html(from: "![Alt text](url)") + XCTAssertEqual(html, #"

Alt text

"#) + } + + func testImageWithReferenceAndAltText() { + let html = MarkdownParser().html(from: """ + ![Alt text][url] + + [url]: swiftbysundell.com + """) + + XCTAssertEqual(html, #"

Alt text

"#) + } + + func testImageWithinParagraph() { + let html = MarkdownParser().html(from: "Text ![](url) text") + XCTAssertEqual(html, #"

Text text

"#) + } +} diff --git a/Tests/MarkdownParserTests/LinkTests.swift b/Tests/MarkdownParserTests/LinkTests.swift new file mode 100644 index 00000000..81c2e644 --- /dev/null +++ b/Tests/MarkdownParserTests/LinkTests.swift @@ -0,0 +1,87 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import XCTest +import Publish + +final class LinkTests: XCTestCase { + func testLinkWithURL() { + let html = MarkdownParser().html(from: "[Title](url)") + XCTAssertEqual(html, #"

Title

"#) + } + + func testLinkWithReference() { + let html = MarkdownParser().html(from: """ + [Title][url] + + [url]: swiftbysundell.com + """) + + XCTAssertEqual(html, #"

Title

"#) + } + + func testCaseMismatchedLinkWithReference() { + let html = MarkdownParser().html(from: """ + [Title][Foo] + [Title][αγω] + + [FOO]: /url + [ΑΓΩ]: /φου + """) + + XCTAssertEqual(html, #"

Title Title

"#) + } + + func testNumericLinkWithReference() { + let html = MarkdownParser().html(from: """ + [1][1] + + [1]: swiftbysundell.com + """) + + XCTAssertEqual(html, #"

1

"#) + } + + func testBoldLinkWithInternalMarkers() { + let html = MarkdownParser().html(from: "[**Hello**](/hello)") + XCTAssertEqual(html, #"

Hello

"#) + } + + func testBoldLinkWithExternalMarkers() { + let html = MarkdownParser().html(from: "**[Hello](/hello)**") + XCTAssertEqual(html, #"

Hello

"#) + } + + func testLinkWithUnderscores() { + let html = MarkdownParser().html(from: "[He_llo](/he_llo)") + XCTAssertEqual(html, "

He_llo

") + } + + func testLinkWithParenthesis() { + let html = MarkdownParser().html(from: "[Hello](/(hello))") + XCTAssertEqual(html, "

Hello

") + } + + func testLinkWithNestedParenthesis() { + let html = MarkdownParser().html(from: "[Hello](/(h(e(l(l(o()))))))") + XCTAssertEqual(html, "

Hello

") + } + + func testLinkWithParenthesisAndClosingParenthesisInContent() { + let html = MarkdownParser().html(from: "[Hello](/(hello)))") + XCTAssertEqual(html, "

Hello)

") + } + + func testUnterminatedLink() { + let html = MarkdownParser().html(from: "[Hello]") + XCTAssertEqual(html, "

[Hello]

") + } + + func testLinkWithEscapedSquareBrackets() { + let html = MarkdownParser().html(from: "[\\[Hello\\]](hello)") + XCTAssertEqual(html, #"

[Hello]

"#) + } +} diff --git a/Tests/MarkdownParserTests/ListTests.swift b/Tests/MarkdownParserTests/ListTests.swift new file mode 100644 index 00000000..df46aad8 --- /dev/null +++ b/Tests/MarkdownParserTests/ListTests.swift @@ -0,0 +1,155 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import XCTest +import Publish + +final class ListTests: XCTestCase { + func testOrderedList() { + let html = MarkdownParser().html(from: """ + 1. One + 2. Two + """) + + XCTAssertEqual(html, #"
  1. One
  2. Two
"#) + } + + func test10DigitOrderedList() { + let html = MarkdownParser().html(from: """ + 1234567890. Not a list + """) + + XCTAssertEqual(html, "

1234567890. Not a list

") + } + + func testOrderedListParentheses() { + let html = MarkdownParser().html(from: """ + 1) One + 2) Two + """) + + XCTAssertEqual(html, #"
  1. One
  2. Two
"#) + } + + func testOrderedListWithoutIncrementedNumbers() { + let html = MarkdownParser().html(from: """ + 1. One + 3. Two + 17. Three + """) + + XCTAssertEqual(html, "
  1. One
  2. Two
  3. Three
") + } + + func testOrderedListWithInvalidNumbers() { + let html = MarkdownParser().html(from: """ + 1. One + 3!. Two + 17. Three + """) + + XCTAssertEqual(html, "
  1. One 3!. Two
  2. Three
") + } + + func testUnorderedList() { + let html = MarkdownParser().html(from: """ + - One + - Two + - Three + """) + + XCTAssertEqual(html, "
  • One
  • Two
  • Three
") + } + + func testMixedUnorderedList() { + let html = MarkdownParser().html(from: """ + - One + * Two + * Three + - Four + """) + + XCTAssertEqual(html, "
  • One
  • Two
  • Three
  • Four
") + } + + func testMixedList() { + let html = MarkdownParser().html(from: """ + 1. One + 2. Two + 3) Three + * Four + """) + + XCTAssertEqual(html, #"
  1. One
  2. Two
  1. Three
  • Four
"#) + } + + func testUnorderedListWithMultiLineItem() { + let html = MarkdownParser().html(from: """ + - One + Some text + - Two + """) + + XCTAssertEqual(html, "
  • One Some text
  • Two
") + } + + func testUnorderedListWithNestedList() { + let html = MarkdownParser().html(from: """ + - A + - B + - B1 + - B11 + - B2 + """) + + let expectedComponents: [String] = [ + "
    ", + "
  • A
  • ", + "
  • B", + "
      ", + "
    • B1", + "
        ", + "
      • B11
      • ", + "
      ", + "
    • ", + "
    • B2
    • ", + "
    ", + "
  • ", + "
" + ] + + XCTAssertEqual(html, expectedComponents.joined()) + } + + func testUnorderedListWithInvalidMarker() { + let html = MarkdownParser().html(from: """ + - One + -Two + - Three + """) + + XCTAssertEqual(html, "
  • One -Two
  • Three
") + } + + func testOrderedIndentedList() { + let html = MarkdownParser().html(from: """ + 1. One + 2. Two + """) + + XCTAssertEqual(html, #"
  1. One
  2. Two
"#) + } + + func testUnorderedIndentedList() { + let html = MarkdownParser().html(from: """ + - One + - Two + - Three + """) + + XCTAssertEqual(html, "
  • One
  • Two
  • Three
") + } +} diff --git a/Tests/MarkdownParserTests/ModifierTests.swift b/Tests/MarkdownParserTests/ModifierTests.swift new file mode 100644 index 00000000..2e695964 --- /dev/null +++ b/Tests/MarkdownParserTests/ModifierTests.swift @@ -0,0 +1,77 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import Testing +import Publish +import Synchronization + +@Suite("Modifier", .serialized) struct ModifierTests { + @Test func testModifierInput() { + let state = Mutex<(allHTML: [String], allMarkdown: [String])>(([], [])) + var parser = MarkdownParser() + + parser.addModifier(for: .paragraph) { html, _, markup in + state.withLock { + $0.allHTML.append(html.render()) + $0.allMarkdown.append(markup.format()) + } + return html + } + + let html = parser.html(from: "One\n\nTwo\n\nThree") + #expect(html == "

One

Two

Three

") + let allHtml = state.withLock(\.allHTML) + let allMarkdown = state.withLock(\.allMarkdown) + #expect(allHtml == ["

One

", "

Two

", "

Three

"]) + #expect(allMarkdown == ["One", "\n\nTwo", "\n\nThree"]) + } + + @Test func testAddingModifiers() { + var parser = MarkdownParser() + parser.addModifier(for: .heading) { _, _, _ in + return .h1(.text("New heading")) + } + parser.addModifier(for: .link) { html, _, _ in + return .group([ + .text("LINK:"), + html + ]) + } + parser.addModifier(for: .inlineCode) { _, _, _ in + return .text("Code") + } + + let html = parser.html(from: """ + # Heading + + Text [Link](url) `code` + """) + + #expect(html == #""" +

New heading

Text LINK:Link Code

+ """#) + } + + @Test func testMultipleModifiersForSameTarget() { + var parser = MarkdownParser() + + parser.addModifier(for: .codeBlock) { html, _, _ in + return .div(html) + } + + parser.addModifier(for: .codeBlock) { html, _, _ in + return .section(html) + } + + let html = parser.html(from: """ + ``` + Code + ``` + """) + + #expect(html == "
Code\n
") + } +} diff --git a/Tests/MarkdownParserTests/TableTests.swift b/Tests/MarkdownParserTests/TableTests.swift new file mode 100644 index 00000000..9d96af27 --- /dev/null +++ b/Tests/MarkdownParserTests/TableTests.swift @@ -0,0 +1,193 @@ +/** + * Ink + * Copyright (c) John Sundell 2020 + * MIT license, see LICENSE file for details + */ + +import XCTest +import Publish + +final class TableTests: XCTestCase { + func testTableWithHeader() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | HeaderC | + | ------- | ------- | ------- | + | CellA1 | CellB1 | CellC1 | + | CellA2 | CellB2 | CellC2 | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ + \ + \ +
HeaderAHeaderBHeaderC
CellA1CellB1CellC1
CellA2CellB2CellC2
+ """) + } + + func testTableWithUnalignedColumns() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | HeaderC | + | ------------------------------ | ----------- | ------------ | + | CellA1 | CellB1 | CellC1 | + | CellA2 | CellB2 | CellC2 | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ + \ + \ +
HeaderAHeaderBHeaderC
CellA1CellB1CellC1
CellA2CellB2CellC2
+ """) + } + + func testTableWithOnlyHeader() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | HeaderC | + | ----------| ----------| ------- | + """) + + XCTAssertEqual(html, """ + \ + \ +
HeaderAHeaderBHeaderC
+ """) + } + + func testIncompleteTable() { + let html = MarkdownParser().html(from: """ + | one | two | + | three | + | four | five | six + """) + + XCTAssertEqual(html, "

| one | two | | three | | four | five | six

") + } + + func testInvalidTable() { + let html = MarkdownParser().html(from: """ + |123 Not a table + """) + + XCTAssertEqual(html, "

|123 Not a table

") + } + + func testTableBetweenParagraphs() { + let html = MarkdownParser().html(from: """ + A paragraph. + + | A | B | + |---|---| + | C | D | + + Another paragraph. + """) + + XCTAssertEqual(html, """ +

A paragraph.

\ + \ + \ +
AB
CD
\ +

Another paragraph.

+ """) + } + + func testTableWithUnevenColumns() { + let html = MarkdownParser().html(from: """ + | one | two | + | --- | --- | + | three | four | five | + + | one | two | + | --- | --- | + | three | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ +
onetwo
threefour
\ + \ + \ + \ + \ +
onetwo
three
+ """) + } + + func testTableWithInternalMarkdown() { + let html = MarkdownParser().html(from: """ + | Table | Header | [Link](/uri) | + | ------ | ---------- | ------------ | + | Some | *emphasis* | and | + | `code` | in | table | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ + \ + \ + \ + \ +
TableHeaderLink
Someemphasisand
codeintable
+ """) + } + + func testTableWithAlignment() { + let html = MarkdownParser().html(from: """ + | Left | Center | Right | + | :- | :-: | -:| + | One | Two | Three | + """) + + XCTAssertEqual(html, """ + \ + \ + \ + \ + \ + \ + \ +
LeftCenterRight
OneTwoThree
+ """) + } + + func testMissingPipeEndsTable() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | + | ------- | ------- | + | CellA | CellB | + > Quote + """) + + XCTAssertEqual(html, """ + \ + \ + \ +
HeaderAHeaderB
CellACellB
\ +

Quote

+ """) + } + + func testHeaderNotParsedForColumnCountMismatch() { + let html = MarkdownParser().html(from: """ + | HeaderA | HeaderB | + | ------- | + | CellA | CellB | + """) + + XCTAssertEqual(html, """ +

| HeaderA | HeaderB | | —–– | | CellA | CellB |

+ """) + } +} diff --git a/Tests/MarkdownParserTests/TextFormattingTests.swift b/Tests/MarkdownParserTests/TextFormattingTests.swift new file mode 100644 index 00000000..3f8459fb --- /dev/null +++ b/Tests/MarkdownParserTests/TextFormattingTests.swift @@ -0,0 +1,174 @@ +/** +* Ink +* Copyright (c) John Sundell 2019 +* MIT license, see LICENSE file for details +*/ + +import XCTest +import Publish + +final class TextFormattingTests: XCTestCase { + func testParagraph() { + let html = MarkdownParser().html(from: "Hello, world!") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testParagraphs() { + let html = MarkdownParser().html(from: "Hello, world!\n\nAgain.") + XCTAssertEqual(html, "

Hello, world!

Again.

") + } + + func xtestDosParagraphs() { + let html = MarkdownParser().html(from: "Hello, world!\r\n\r\nAgain.") + XCTAssertEqual(html, "

Hello, world!

Again.

") + } + + func testItalicText() { + let html = MarkdownParser().html(from: "Hello, *world*!") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testBoldText() { + let html = MarkdownParser().html(from: "Hello, **world**!") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testItalicBoldText() { + let html = MarkdownParser().html(from: "Hello, ***world***!") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testItalicBoldTextWithSeparateStartMarkers() { + let html = MarkdownParser().html(from: "**Hello, *world***!") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testItalicTextWithinBoldText() { + let html = MarkdownParser().html(from: "**Hello, *world*!**") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testBoldTextWithinItalicText() { + let html = MarkdownParser().html(from: "*Hello, **world**!*") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testItalicTextWithExtraLeadingMarkers() { + let html = MarkdownParser().html(from: "**Hello*") + XCTAssertEqual(html, "

*Hello

") + } + + func testBoldTextWithExtraLeadingMarkers() { + let html = MarkdownParser().html(from: "***Hello**") + XCTAssertEqual(html, "

*Hello

") + } + + func testItalicTextWithExtraTrailingMarkers() { + let html = MarkdownParser().html(from: "*Hello**") + XCTAssertEqual(html, "

Hello*

") + } + + func testBoldTextWithExtraTrailingMarkers() { + let html = MarkdownParser().html(from: "**Hello***") + XCTAssertEqual(html, "

Hello*

") + } + + func testItalicBoldTextWithExtraTrailingMarkers() { + let html = MarkdownParser().html(from: "**Hello, *world*****!") + XCTAssertEqual(html, "

Hello, world**!

") + } + + func testUnterminatedItalicMarker() { + let html = MarkdownParser().html(from: "*Hello") + XCTAssertEqual(html, "

*Hello

") + } + + func testUnterminatedBoldMarker() { + let html = MarkdownParser().html(from: "**Hello") + XCTAssertEqual(html, "

**Hello

") + } + + func testUnterminatedItalicBoldMarker() { + let html = MarkdownParser().html(from: "***Hello") + XCTAssertEqual(html, "

***Hello

") + } + + func testUnterminatedItalicMarkerWithinBoldText() { + let html = MarkdownParser().html(from: "**Hello, *world!**") + XCTAssertEqual(html, "

*Hello, world!

") + } + + func testUnterminatedBoldMarkerWithinItalicText() { + let html = MarkdownParser().html(from: "*Hello, **world!*") + XCTAssertEqual(html, "

*Hello, *world!

") + } + + func testStrikethroughText() { + let html = MarkdownParser().html(from: "Hello, ~~world!~~") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testSingleTildeWithinStrikethroughText() { + let html = MarkdownParser().html(from: "Hello, ~~wor~ld!~~") + XCTAssertEqual(html, "

Hello, wor~ld!

") + } + + func testUnterminatedStrikethroughMarker() { + let html = MarkdownParser().html(from: "~~Hello") + XCTAssertEqual(html, "

~~Hello

") + } + + func testEncodingSpecialCharacters() { + let html = MarkdownParser().html(from: "Hello < World & >") + XCTAssertEqual(html, "

Hello < World & >

") + } + + func testSingleLineBlockquote() { + let html = MarkdownParser().html(from: "> Hello, world!") + XCTAssertEqual(html, "

Hello, world!

") + } + + func testMultiLineBlockquote() { + let html = MarkdownParser().html(from: """ + > One + > Two + > Three + """) + + XCTAssertEqual(html, "

One Two Three

") + } + + func testEscapingSymbolsWithBackslash() { + let html = MarkdownParser().html(from: """ + \\# Not a title + \\*Not italic\\* + """) + + XCTAssertEqual(html, "

# Not a title *Not italic*

") + } + + + func testListAfterFormattedText() { + let html = MarkdownParser().html(from: """ + This is a test + - One + - Two + """) + + XCTAssertEqual(html, """ +

This is a test

  • One
  • Two
+ """) + } + + func testDoubleSpacedHardLinebreak() { + let html = MarkdownParser().html(from: "Line 1 \nLine 2") + + XCTAssertEqual(html, "

Line 1
Line 2

") + } + + func testEscapedHardLinebreak() { + let html = MarkdownParser().html(from: "Line 1\\\nLine 2") + + XCTAssertEqual(html, "

Line 1
Line 2

") + } +} diff --git a/Tests/PublishTests/Infrastructure/HTMLFactoryMock.swift b/Tests/PublishTests/Infrastructure/HTMLFactoryMock.swift index 5f63fe92..77e0a157 100644 --- a/Tests/PublishTests/Infrastructure/HTMLFactoryMock.swift +++ b/Tests/PublishTests/Infrastructure/HTMLFactoryMock.swift @@ -7,15 +7,31 @@ import Publish import Plot -final class HTMLFactoryMock: HTMLFactory { - typealias Closure = (T, PublishingContext) throws -> HTML - - var makeIndexHTML: Closure = { _, _ in HTML(.body()) } - var makeSectionHTML: Closure> = { _, _ in HTML(.body()) } - var makeItemHTML: Closure> = { _, _ in HTML(.body()) } - var makePageHTML: Closure = { _, _ in HTML(.body()) } - var makeTagListHTML: Closure? = { _, _ in HTML(.body()) } - var makeTagDetailsHTML: Closure? = { _, _ in HTML(.body()) } +struct HTMLFactoryMock: HTMLFactory { + typealias Closure = @Sendable (T, PublishingContext) throws -> HTML + + init( + makeIndexHTML: @escaping Closure = { _, _ in HTML(.body()) }, + makeSectionHTML: @escaping Closure> = { _, _ in HTML(.body()) }, + makeItemHTML: @escaping Closure> = { _, _ in HTML(.body()) }, + makePageHTML: @escaping Closure = { _, _ in HTML(.body()) }, + makeTagListHTML: Closure? = { _, _ in HTML(.body()) }, + makeTagDetailsHTML: Closure? = { _, _ in HTML(.body()) }, + ) { + self.makeIndexHTML = makeIndexHTML + self.makeSectionHTML = makeSectionHTML + self.makeItemHTML = makeItemHTML + self.makePageHTML = makePageHTML + self.makeTagListHTML = makeTagListHTML + self.makeTagDetailsHTML = makeTagDetailsHTML + } + + let makeIndexHTML: Closure + let makeSectionHTML: Closure> + let makeItemHTML: Closure> + let makePageHTML: Closure + let makeTagListHTML: Closure? + let makeTagDetailsHTML: Closure? func makeIndexHTML(for index: Index, context: PublishingContext) throws -> HTML { diff --git a/Tests/PublishTests/Infrastructure/Item+Stubbable.swift b/Tests/PublishTests/Infrastructure/Item+Stubbable.swift index 0a80ba56..41130221 100644 --- a/Tests/PublishTests/Infrastructure/Item+Stubbable.swift +++ b/Tests/PublishTests/Infrastructure/Item+Stubbable.swift @@ -7,7 +7,7 @@ import Foundation import Publish -extension Item: Stubbable where Site == WebsiteStub.WithoutItemMetadata { +extension Item: Stubbable where Site == WithoutItemMetadata { private static let defaultDate = Date() static func stub(withPath path: Path) -> Self { @@ -23,11 +23,11 @@ extension Item: Stubbable where Site == WebsiteStub.WithoutItemMetadata { ) } - static func stub(withSectionID sectionID: WebsiteStub.SectionID) -> Self { + static func stub(withSectionID sectionID: Site.SectionID) -> Self { stub(withPath: Path(.unique()), sectionID: sectionID) } - static func stub(withPath path: Path, sectionID: WebsiteStub.SectionID) -> Self { + static func stub(withPath path: Path, sectionID: Site.SectionID) -> Self { Item( path: path, sectionID: sectionID, diff --git a/Tests/PublishTests/Infrastructure/PublishTestCase.swift b/Tests/PublishTests/Infrastructure/PublishTestCase.swift index c9f12ef5..afa31141 100644 --- a/Tests/PublishTests/Infrastructure/PublishTestCase.swift +++ b/Tests/PublishTests/Infrastructure/PublishTestCase.swift @@ -9,31 +9,37 @@ import Publish import Plot import Files -class PublishTestCase: XCTestCase { +protocol PublishTestCase {} + +extension PublishTestCase { @discardableResult func publishWebsite( in folder: Folder? = nil, - using steps: [PublishingStep], + using steps: [PublishingStep], + deploy: Bool = false, content: [Path : String] = [:] - ) throws -> PublishedWebsite { + ) throws -> PublishedWebsite { try performWebsitePublishing( + site: WithoutItemMetadata(), in: folder, using: steps, + deploy: deploy, files: content, - filePathPrefix: "Content/" + filePathPrefix: "Content/", ) } func publishWebsite( - _ site: WebsiteStub.WithoutItemMetadata = .init(), + _ site: WithoutItemMetadata = .init(), in folder: Folder? = nil, - using theme: Theme, + using theme: Theme, content: [Path : String] = [:], - additionalSteps: [PublishingStep] = [], - plugins: [Plugin] = [], + additionalSteps: [PublishingStep] = [], + plugins: [Plugin] = [], expectedHTML: [Path : String], allowWhitelistedOutputFiles: Bool = true, - file: StaticString = #file, + deploy: Bool = false, + file: StaticString = #filePath, line: UInt = #line ) throws { let folder = try folder ?? Folder.createTemporary() @@ -49,7 +55,8 @@ class PublishTestCase: XCTestCase { at: Path(folder.path), rssFeedSections: [], additionalSteps: additionalSteps, - plugins: plugins + plugins: plugins, + deploy: deploy, ) try verifyOutput( @@ -63,14 +70,17 @@ class PublishTestCase: XCTestCase { func publishWebsiteWithPodcast( in folder: Folder? = nil, - using steps: [PublishingStep], + using steps: [PublishingStep], content: [Path : String] = [:], + deploy: Bool = false, file: StaticString = #file, line: UInt = #line ) throws { try performWebsitePublishing( + site: WithPodcastMetadata(), in: folder, using: steps, + deploy: deploy, files: content, filePathPrefix: "Content/" ) @@ -79,7 +89,7 @@ class PublishTestCase: XCTestCase { func verifyOutput(in folder: Folder, expectedHTML: [Path : String], allowWhitelistedFiles: Bool = true, - file: StaticString = #file, + file: StaticString = #filePath, line: UInt = #line) throws { let outputFolder = try folder.subfolder(named: "Output") @@ -135,21 +145,24 @@ class PublishTestCase: XCTestCase { @discardableResult func publishWebsite( withItemMetadataType itemMetadataType: T.Type, - using steps: [PublishingStep>], - content: [Path : String] = [:] - ) throws -> PublishedWebsite> { + using steps: [PublishingStep>], + content: [Path : String] = [:], + deploy: Bool = false + ) throws -> PublishedWebsite> { try performWebsitePublishing( + site: .init(), using: steps, + deploy: deploy, files: content, filePathPrefix: "Content/" ) } func generateItem( - in section: WebsiteStub.SectionID = .one, + in section: WithoutItemMetadata.SectionID = .one, fromMarkdown markdown: String, fileName: String = "markdown.md" - ) throws -> Item { + ) throws -> Item { let site = try publishWebsite( using: [ .addMarkdownFiles() @@ -164,10 +177,10 @@ class PublishTestCase: XCTestCase { func generateItem( withMetadataType metadataType: T.Type, - in section: WebsiteStub.SectionID = .one, + in section: WithItemMetadata.SectionID = .one, fromMarkdown markdown: String, fileName: String = "markdown.md" - ) throws -> Item> { + ) throws -> Item> { let site = try publishWebsite( withItemMetadataType: T.self, using: [ @@ -193,9 +206,11 @@ private extension PublishTestCase { } @discardableResult - func performWebsitePublishing( + func performWebsitePublishing( + site: T, in folder: Folder? = nil, using steps: [PublishingStep], + deploy: Bool, files: [Path : String], filePathPrefix: String = "" ) throws -> PublishedWebsite { @@ -203,9 +218,10 @@ private extension PublishTestCase { try addFiles(withContent: files, to: folder, pathPrefix: filePathPrefix) - return try T().publish( + return try site.publish( at: Path(folder.path), - using: steps + using: steps, + deploy: deploy ) } } diff --git a/Tests/PublishTests/Infrastructure/WebsiteStub.swift b/Tests/PublishTests/Infrastructure/WebsiteStub.swift index 09c3638f..98556f22 100644 --- a/Tests/PublishTests/Infrastructure/WebsiteStub.swift +++ b/Tests/PublishTests/Infrastructure/WebsiteStub.swift @@ -8,36 +8,54 @@ import Foundation import Publish import Plot -class WebsiteStub { +struct WithItemMetadata: Website { enum SectionID: String, WebsiteSectionID { case one, two, three, customRawValue = "custom-raw-value" } - var url = URL(string: "https://swiftbysundell.com")! - var name = "WebsiteName" - var description = "Description" - var language = Language.english - var imagePath: Path? = nil - var faviconPath: Path? = nil + var url: URL = URL(string: "https://swiftbysundell.com")! + var name: String = "WebsiteName" + var description: String = "Description" + var language: Plot.Language = .english + var imagePath: Publish.Path? = nil + var faviconPath: Favicon? = nil var tagHTMLConfig: TagHTMLConfiguration? = .default - required init() {} - - func title(for sectionID: WebsiteStub.SectionID) -> String { + func title(for sectionID: SectionID) -> String { sectionID.rawValue } } -extension WebsiteStub { - final class WithItemMetadata: WebsiteStub, Website {} +struct WithPodcastMetadata: Website { + enum SectionID: String, WebsiteSectionID { + case one, two, three, customRawValue = "custom-raw-value" + } - final class WithPodcastMetadata: WebsiteStub, Website { - struct ItemMetadata: PodcastCompatibleWebsiteItemMetadata { - var podcast: PodcastEpisodeMetadata? - } + struct ItemMetadata: PodcastCompatibleWebsiteItemMetadata { + var podcast: PodcastEpisodeMetadata? } - final class WithoutItemMetadata: WebsiteStub, Website { - struct ItemMetadata: WebsiteItemMetadata {} + var url: URL = URL(string: "https://swiftbysundell.com")! + var name: String = "WebsiteName" + var description: String = "Description" + var language: Plot.Language = .english + var imagePath: Publish.Path? = nil + var faviconPath: Favicon? = nil + var tagHTMLConfig: TagHTMLConfiguration? = .default +} + +struct WithoutItemMetadata: Website { + var url: URL = URL(string: "https://swiftbysundell.com")! + var name: String = "WebsiteName" + var description: String = "Description" + var language: Plot.Language = .english + var imagePath: Publish.Path? = nil + var faviconPath: Favicon? = nil + var tagHTMLConfig: TagHTMLConfiguration? = .default + + enum SectionID: String, WebsiteSectionID { + case one, two, three, customRawValue = "custom-raw-value" } + + struct ItemMetadata: WebsiteItemMetadata {} } diff --git a/Tests/PublishTests/Tests/CLITests.swift b/Tests/PublishTests/Tests/CLITests.swift index caf0a7ac..b3a1ad73 100644 --- a/Tests/PublishTests/Tests/CLITests.swift +++ b/Tests/PublishTests/Tests/CLITests.swift @@ -4,13 +4,14 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing import PublishCLICore import Files import ShellOut +import Foundation -final class CLITests: PublishTestCase { - func testWebsiteProjectGeneration() throws { +@Suite("CLI", .serialized) struct CLITests: PublishTestCase { + @Test func testWebsiteProjectGeneration() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary() try makeCLI(in: folder, command: "new").run(in: folder) @@ -18,73 +19,73 @@ final class CLITests: PublishTestCase { #endif } - func testPluginProjectGeneration() throws { + @Test func testPluginProjectGeneration() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary(named: "Name") try makeCLI(in: folder, command: "new", "plugin").run(in: folder) - XCTAssertTrue(folder.containsFile(at: "Sources/Name/Name.swift")) - XCTAssertEqual(try folder.getPackageName(), "Name") + #expect(folder.containsFile(at: "Sources/Name/Name.swift")) + #expect(try folder.getPackageName() == "Name") // Make sure that the project can build try shellOut(to: "swift build", at: folder.path) #endif } - func testSiteName() throws { + @Test func testSiteName() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary(named: "Name") try makeCLI(in: folder, command: "new").run(in: folder) - XCTAssertEqual(try folder.getPackageName(), "Name") + #expect(try folder.getPackageName() == "Name") #endif } - func testSiteNameFromLowercasedFolderName() throws { + @Test func testSiteNameFromLowercasedFolderName() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary(named: "name") try makeCLI(in: folder, command: "new").run(in: folder) - XCTAssertEqual(try folder.getPackageName(), "Name") + #expect(try folder.getPackageName() == "Name") #endif } - func testSiteNameFromFolderNameStartingWithDigit() throws { + @Test func testSiteNameFromFolderNameStartingWithDigit() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary(named: "1-name") try makeCLI(in: folder, command: "new").run(in: folder) - XCTAssertEqual(try folder.getPackageName(), "Name") + #expect(try folder.getPackageName() == "Name") #endif } - func testSiteNameFromCamelCaseFolderName() throws { + @Test func testSiteNameFromCamelCaseFolderName() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary(named: "CamelCaseName") try makeCLI(in: folder, command: "new").run(in: folder) - XCTAssertEqual(try folder.getPackageName(), "CamelCaseName") + #expect(try folder.getPackageName() == "CamelCaseName") #endif } - func testSiteNameWithNonLetterValidCharactersFolderName() throws { + @Test func testSiteNameWithNonLetterValidCharactersFolderName() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary(named: "Blog.CamelCaseName2.com") try makeCLI(in: folder, command: "new").run(in: folder) - XCTAssertEqual(try folder.getPackageName(), "BlogCamelCaseName2Com") + #expect(try folder.getPackageName() == "BlogCamelCaseName2Com") #endif } - func testSiteNameFromFolderNameWithNonLetters() throws { + @Test func testSiteNameFromFolderNameWithNonLetters() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary(named: "My website 1") try makeCLI(in: folder, command: "new").run(in: folder) - XCTAssertEqual(try folder.getPackageName(), "MyWebsite") + #expect(try folder.getPackageName() == "MyWebsite") #endif } - func testSiteNameFromDigitsOnlyFolderName() throws { + @Test func testSiteNameFromDigitsOnlyFolderName() throws { #if INCLUDE_CLI let folder = try Folder.createTemporary(named: "1") try makeCLI(in: folder, command: "new").run(in: folder) let name = try folder.getPackageName() - XCTAssertFalse(name.isEmpty) + #expect(!name.isEmpty) #endif } } diff --git a/Tests/PublishTests/Tests/ContentMutationTests.swift b/Tests/PublishTests/Tests/ContentMutationTests.swift index fa1e5300..df4fa05c 100644 --- a/Tests/PublishTests/Tests/ContentMutationTests.swift +++ b/Tests/PublishTests/Tests/ContentMutationTests.swift @@ -4,35 +4,41 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing import Publish +import Synchronization +import Foundation -final class ContentMutationTests: PublishTestCase { - func testAddingItemUsingClosureAPI() throws { +@Suite("Content Mutation", .serialized) struct ContentMutationTests: PublishTestCase { + @Test func testAddingItemUsingClosureAPI() throws { let site = try publishWebsite(using: [ .step(named: "Custom") { context in - context.sections[.one].addItem(at: "path", withMetadata: .init()) { item in + context.addItem( + to: .one, + at: "path", + withMetadata: .init() + ) { item in item.title = "Hello, world!" } } ]) - XCTAssertEqual(site.sections[.one].items.count, 1) - XCTAssertEqual(site.sections[.one].items.first?.title, "Hello, world!") + #expect(site.sections[.one].items.count == 1) + #expect(site.sections[.one].items.first?.title == "Hello, world!") } - func testAddingItemUsingPlotHierarchy() throws { + @Test func testAddingItemUsingPlotHierarchy() throws { let site = try publishWebsite(using: [ .addItem(Item.stub().setting(\.body, to: Content.Body(node: .div("Plot!")) )) ]) - XCTAssertEqual(site.sections[.one].items.count, 1) - XCTAssertEqual(site.sections[.one].items.first?.body.html, "
Plot!
") + #expect(site.sections[.one].items.count == 1) + #expect(site.sections[.one].items.first?.body.html == "
Plot!
") } - func testRemovingItemsMatchingPredicate() throws { + @Test func testRemovingItemsMatchingPredicate() throws { let items = [ Item.stub(withPath: "a").setting(\.tags, to: ["one"]), Item.stub(withPath: "b").setting(\.tags, to: ["one", "two"]) @@ -43,11 +49,11 @@ final class ContentMutationTests: PublishTestCase { .removeAllItems(matching: \.tags ~= "two") ]) - XCTAssertEqual(site.sections[.one].items, [items[0]]) - XCTAssertNil(site.sections[.one].item(at: "b"), "Item indexes not updated") + #expect(site.sections[.one].items == [items[0]]) + #expect(site.sections[.one].item(at: "b") == nil, "Item indexes not updated") } - func testMutatingAllSections() throws { + @Test func testMutatingAllSections() throws { let site = try publishWebsite(using: [ .step(named: "Set section titles") { context in context.mutateAllSections { section in @@ -56,12 +62,12 @@ final class ContentMutationTests: PublishTestCase { } ]) - XCTAssertEqual(site.sections[.one].title, "one") - XCTAssertEqual(site.sections[.two].title, "two") - XCTAssertEqual(site.sections[.three].title, "three") + #expect(site.sections[.one].title == "one") + #expect(site.sections[.two].title == "two") + #expect(site.sections[.three].title == "three") } - func testMutatingAllItems() throws { + @Test func testMutatingAllItems() throws { let site = try publishWebsite(using: [ .addItem(.stub(withSectionID: .one)), .addItem(.stub(withSectionID: .two)), @@ -71,16 +77,16 @@ final class ContentMutationTests: PublishTestCase { } ]) - XCTAssertEqual(site.sections[.one].items.count, 1) - XCTAssertEqual(site.sections[.two].items.count, 1) - XCTAssertEqual(site.sections[.three].items.count, 1) + #expect(site.sections[.one].items.count == 1) + #expect(site.sections[.two].items.count == 1) + #expect(site.sections[.three].items.count == 1) - XCTAssertEqual(site.sections[.one].items.first?.title, "Mutated title") - XCTAssertEqual(site.sections[.two].items.first?.title, "Mutated title") - XCTAssertEqual(site.sections[.three].items.first?.title, "Mutated title") + #expect(site.sections[.one].items.first?.title == "Mutated title") + #expect(site.sections[.two].items.first?.title == "Mutated title") + #expect(site.sections[.three].items.first?.title == "Mutated title") } - func testMutatingItemsInSection() throws { + @Test func testMutatingItemsInSection() throws { let site = try publishWebsite(using: [ .addItem(.stub(withSectionID: .one)), .addItem(.stub(withSectionID: .two)), @@ -90,16 +96,16 @@ final class ContentMutationTests: PublishTestCase { } ]) - XCTAssertEqual(site.sections[.one].items.count, 1) - XCTAssertEqual(site.sections[.two].items.count, 1) - XCTAssertEqual(site.sections[.three].items.count, 1) + #expect(site.sections[.one].items.count == 1) + #expect(site.sections[.two].items.count == 1) + #expect(site.sections[.three].items.count == 1) - XCTAssertEqual(site.sections[.one].items.first?.title, "Mutated title") - XCTAssertEqual(site.sections[.two].items.first?.title, "") - XCTAssertEqual(site.sections[.three].items.first?.title, "") + #expect(site.sections[.one].items.first?.title == "Mutated title") + #expect(site.sections[.two].items.first?.title == "") + #expect(site.sections[.three].items.first?.title == "") } - func testMutatingItemsMatchingPredicate() throws { + @Test func testMutatingItemsMatchingPredicate() throws { var items = [ Item.stub(withPath: "a").setting(\.tags, to: ["one"]), Item.stub(withPath: "b").setting(\.tags, to: ["one", "two"]) @@ -118,17 +124,17 @@ final class ContentMutationTests: PublishTestCase { items[0].title = "One" items[1].title = "One Two" - XCTAssertEqual(Array(site.sections[.one].items), items) + #expect(Array(site.sections[.one].items) == items) } - func testMutatingItemsByChangingTags() throws { + @Test func testMutatingItemsByChangingTags() throws { var items = [ Item.stub(withPath: "a").setting(\.tags, to: ["first"]), Item.stub(withPath: "b").setting(\.tags, to: ["first"]), Item.stub(withPath: "c").setting(\.tags, to: ["first"]) ] - var allTags: Set? + let allTags: Mutex?> = .init(nil) let site = try publishWebsite(using: [ .addItems(in: items), @@ -142,7 +148,7 @@ final class ContentMutationTests: PublishTestCase { item.tags = [] }, .step(named: "custom") { context in - allTags = context.allTags + allTags.withLock { $0 = context.allTags } } ]) @@ -150,13 +156,13 @@ final class ContentMutationTests: PublishTestCase { items[1].tags = ["replaced"] items[2].tags = [] - XCTAssertEqual(site.sections[.one].items, items) - XCTAssertEqual(allTags, ["first", "added", "replaced"]) + #expect(site.sections[.one].items == items) + let tags = allTags.withLock(\.self) + #expect(tags == ["first", "added", "replaced"]) } - func testMutatingItemsByRemovingTags() throws { - var initialTags: Set? - var finalTags: Set? + @Test func testMutatingItemsByRemovingTags() throws { + let state = Mutex<(initialTags:Set?, finalTags: Set?)>.init((nil, nil)) try publishWebsite(using: [ .addItems(in: [ @@ -165,21 +171,22 @@ final class ContentMutationTests: PublishTestCase { Item.stub(withPath: "c").setting(\.tags, to: ["three"]) ]), .step(named: "custom") { context in - initialTags = context.allTags + state.withLock { $0.initialTags = context.allTags } }, .mutateAllItems { item in item.tags = [] }, .step(named: "custom") { context in - finalTags = context.allTags + state.withLock { $0.finalTags = context.allTags } } ]) - - XCTAssertEqual(initialTags, ["one", "two", "three"]) - XCTAssertEqual(finalTags, []) + let initialTags = state.withLock(\.initialTags) + #expect(initialTags == ["one", "two", "three"]) + let finalTags = state.withLock(\.finalTags) + #expect(finalTags == []) } - func testSortingItems() throws { + @Test func testSortingItems() throws { let items = [ Item.stub(withPath: "a").setting(\.title, to: "A"), Item.stub(withPath: "b").setting(\.title, to: "B"), @@ -196,20 +203,20 @@ final class ContentMutationTests: PublishTestCase { .sortItems(by: \.title, order: .descending) ]) - XCTAssertEqual(ascendingSite.sections[.one].items, items) - XCTAssertEqual(descendingSite.sections[.one].items, items.reversed()) + #expect(ascendingSite.sections[.one].items == items) + #expect(descendingSite.sections[.one].items == items.reversed()) // Make sure path associations are still valid - XCTAssertEqual(ascendingSite.sections[.one].item(at: "a"), items[0]) - XCTAssertEqual(ascendingSite.sections[.one].item(at: "b"), items[1]) - XCTAssertEqual(ascendingSite.sections[.one].item(at: "c"), items[2]) + #expect(ascendingSite.sections[.one].item(at: "a") == items[0]) + #expect(ascendingSite.sections[.one].item(at: "b") == items[1]) + #expect(ascendingSite.sections[.one].item(at: "c") == items[2]) - XCTAssertEqual(descendingSite.sections[.one].item(at: "a"), items[0]) - XCTAssertEqual(descendingSite.sections[.one].item(at: "b"), items[1]) - XCTAssertEqual(descendingSite.sections[.one].item(at: "c"), items[2]) + #expect(descendingSite.sections[.one].item(at: "a") == items[0]) + #expect(descendingSite.sections[.one].item(at: "b") == items[1]) + #expect(descendingSite.sections[.one].item(at: "c") == items[2]) } - func testSortingItemsInSection() throws { + @Test func testSortingItemsInSection() throws { let items = [ Item.stub(withSectionID: .one).setting(\.title, to: "A"), Item.stub(withSectionID: .one).setting(\.title, to: "B"), @@ -222,11 +229,11 @@ final class ContentMutationTests: PublishTestCase { .sortItems(in: .one, by: \.title, order: .descending) ]) - XCTAssertEqual(site.sections[.one].items, items[0..<2].reversed()) - XCTAssertEqual(site.sections[.two].items, Array(items[2..<4])) + #expect(site.sections[.one].items == items[0..<2].reversed()) + #expect(site.sections[.two].items == Array(items[2..<4])) } - func testMutatingItemUsingContentProxyProperties() throws { + @Test func testMutatingItemUsingContentProxyProperties() throws { let audio = Audio(url: try require(URL(string: "audio.mp3"))) let site = try publishWebsite(using: [ @@ -243,15 +250,15 @@ final class ContentMutationTests: PublishTestCase { let item = try require(site.sections[.one].item(at: "item")) - XCTAssertEqual(item.title, "Title") - XCTAssertEqual(item.description, "Description") - XCTAssertEqual(item.body, "

Body

") - XCTAssertEqual(item.imagePath, "image.png") - XCTAssertEqual(item.audio, audio) - XCTAssertEqual(item.video, .youTube(id: "123")) + #expect(item.title == "Title") + #expect(item.description == "Description") + #expect(item.body == "

Body

") + #expect(item.imagePath == "image.png") + #expect(item.audio == audio) + #expect(item.video == .youTube(id: "123")) } - func testMutatingPage() throws { + @Test func testMutatingPage() throws { let site = try publishWebsite(using: [ .addPage(.stub(withPath: "a")), .mutatePage(at: "a", using: { page in @@ -259,10 +266,10 @@ final class ContentMutationTests: PublishTestCase { }) ]) - XCTAssertEqual(site.pages["a"]?.title, "A: Mutated") + #expect(site.pages["a"]?.title == "A: Mutated") } - func testMutatingPageByChangingPath() throws { + @Test func testMutatingPageByChangingPath() throws { let site = try publishWebsite(using: [ .addPage(.stub(withPath: "a")), .mutatePage(at: "a", using: { page in @@ -270,11 +277,11 @@ final class ContentMutationTests: PublishTestCase { }) ]) - XCTAssertNil(site.pages["a"]) - XCTAssertNotNil(site.pages["b"]) + #expect(site.pages["a"] == nil) + #expect(site.pages["b"] != nil) } - func testMutatingAllPagesMatchingPredicate() throws { + @Test func testMutatingAllPagesMatchingPredicate() throws { let site = try publishWebsite(using: [ .addPages(in: [ .stub(withPath: "a"), @@ -285,7 +292,7 @@ final class ContentMutationTests: PublishTestCase { } ]) - XCTAssertEqual(site.pages["a"]?.title, "A: Mutated") - XCTAssertEqual(site.pages["b"]?.title, "") + #expect(site.pages["a"]?.title == "A: Mutated") + #expect(site.pages["b"]?.title == "") } } diff --git a/Tests/PublishTests/Tests/DeploymentTests.swift b/Tests/PublishTests/Tests/DeploymentTests.swift index 6bc4a056..60384c12 100644 --- a/Tests/PublishTests/Tests/DeploymentTests.swift +++ b/Tests/PublishTests/Tests/DeploymentTests.swift @@ -4,58 +4,54 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing +import Foundation import Publish import Files import ShellOut +import Synchronization -final class DeploymentTests: PublishTestCase { - private var defaultCommandLineArguments: [String]! - - override func setUp() { - super.setUp() - defaultCommandLineArguments = CommandLine.arguments - } - - override func tearDown() { - CommandLine.arguments = defaultCommandLineArguments - super.tearDown() - } - - func testDeploymentSkippedByDefault() throws { - var deployed = false +@Suite("Deployment", .serialized) struct DeploymentTests: PublishTestCase { + @Test func testDeploymentSkippedByDefault() throws { + let deployed = Mutex(false) try publishWebsite(using: [ .step(named: "Custom") { _ in }, .deploy(using: DeploymentMethod(name: "Deploy") { _ in - deployed = true + deployed.withLock { $0 = true } }) ]) - XCTAssertFalse(deployed) + let isDeployed = deployed.withLock(\.self) + #expect(!isDeployed) } - func testGenerationStepsAndPluginsSkippedWhenDeploying() throws { - CommandLine.arguments.append("--deploy") + @Test func testGenerationStepsAndPluginsSkippedWhenDeploying() throws { + + let generationPerformed = Mutex(false) + let pluginInstalled = Mutex(false) + + try publishWebsite( + using: [ + .step(named: "Skipped") { _ in + generationPerformed.withLock { $0 = true } + }, + .installPlugin(Plugin(name: "Skipped") { _ in + pluginInstalled.withLock { $0 = true } + }), + .deploy(using: DeploymentMethod(name: "Deploy") { _ in }) + ], + deploy: true + ) - var generationPerformed = false - var pluginInstalled = false + let isGenerationPerformed = generationPerformed.withLock(\.self) + #expect(isGenerationPerformed == false) - try publishWebsite(using: [ - .step(named: "Skipped") { _ in - generationPerformed = true - }, - .installPlugin(Plugin(name: "Skipped") { _ in - pluginInstalled = true - }), - .deploy(using: DeploymentMethod(name: "Deploy") { _ in }) - ]) - - XCTAssertFalse(generationPerformed) - XCTAssertFalse(pluginInstalled) + let isPluginInstalled = pluginInstalled.withLock(\.self) + #expect(isPluginInstalled == false) } - func testGitDeploymentMethod() throws { + @Test func testGitDeploymentMethod() throws { let container = try Folder.createTemporary() let remote = try container.createSubfolder(named: "Remote.git") let repo = try container.createSubfolder(named: "Repo") @@ -73,17 +69,19 @@ final class DeploymentTests: PublishTestCase { ]) // Then deploy - CommandLine.arguments.append("--deploy") - - try publishWebsite(in: repo, using: [ - .deploy(using: .git(remote.path)) - ]) + try publishWebsite( + in: repo, + using: [ + .deploy(using: .git(remote.path)) + ], + deploy: true + ) let indexFile = try remote.file(named: "index.html") - XCTAssertFalse(try indexFile.readAsString().isEmpty) + #expect(try !indexFile.readAsString().isEmpty) } - func testGitDeploymentMethodWithError() throws { + @Test func testGitDeploymentMethodWithError() throws { let container = try Folder.createTemporary() let remote = try container.createSubfolder(named: "Remote.git") let repo = try container.createSubfolder(named: "Repo") @@ -103,14 +101,13 @@ final class DeploymentTests: PublishTestCase { ]) // Then deploy - CommandLine.arguments.append("--deploy") - var thrownError: PublishingError? do { try publishWebsite( in: repo, - using: [.deploy(using: .git(remote.path))] + using: [.deploy(using: .git(remote.path))], + deploy: true ) } catch { thrownError = error as? PublishingError @@ -120,11 +117,11 @@ final class DeploymentTests: PublishTestCase { // Git phrases its error messages here, so we just perform // a few basic checks to make sure we have some form of output: let infoMessage = try require(thrownError?.infoMessage) - XCTAssertTrue(infoMessage.contains("receive.denyCurrentBranch")) - XCTAssertTrue(infoMessage.contains("[remote rejected]")) + #expect(infoMessage.contains("receive.denyCurrentBranch")) + #expect(infoMessage.contains("[remote rejected]")) } - func testDeployingUsingCustomOutputFolder() throws { + @Test func testDeployingUsingCustomOutputFolder() throws { let container = try Folder.createTemporary() // First generate @@ -136,22 +133,26 @@ final class DeploymentTests: PublishTestCase { ]) // Then deploy - CommandLine.arguments.append("--deploy") - - var outputFolder: Folder? - - try publishWebsite(in: container, using: [ - .deploy(using: DeploymentMethod(name: "Test") { context in - outputFolder = try context.createDeploymentFolder( - withPrefix: "Test", - outputFolderPath: "CustomOutput", - configure: { _ in } - ) - }) - ]) + let outputFolder = Mutex(nil) + + try publishWebsite( + in: container, + using: [ + .deploy(using: DeploymentMethod(name: "Test") { context in + try outputFolder.withLock { + $0 = try context.createDeploymentFolder( + withPrefix: "Test", + outputFolderPath: "CustomOutput", + configure: { _ in } + ) + } + }) + ], + deploy: true + ) - let folder = try require(outputFolder) + let folder = try require(outputFolder.withLock(\.self)) let subfolder = try folder.subfolder(named: "CustomOutput") - XCTAssertTrue(subfolder.containsSubfolder(at: "one/a")) + #expect(subfolder.containsSubfolder(at: "one/a")) } } diff --git a/Tests/PublishTests/Tests/ErrorTests.swift b/Tests/PublishTests/Tests/ErrorTests.swift index bda3c915..73b2cd55 100644 --- a/Tests/PublishTests/Tests/ErrorTests.swift +++ b/Tests/PublishTests/Tests/ErrorTests.swift @@ -4,24 +4,25 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing import Publish - -final class ErrorTests: PublishTestCase { - func testErrorForInvalidRootPath() throws { - assertErrorThrown( - try WebsiteStub.WithoutItemMetadata().publish( +import Foundation + +@Suite("Error", .serialized) struct ErrorTests: PublishTestCase { + @Test func testErrorForInvalidRootPath() throws { + #expect(throws: PublishingError( + path: "🤷‍♂️", + infoMessage: "Could not find the requested root folder" + )) { + try WithoutItemMetadata().publish( at: "🤷‍♂️", - using: [] - ), - PublishingError( - path: "🤷‍♂️", - infoMessage: "Could not find the requested root folder" + using: [], + deploy: false ) - ) + } } - func testErrorForMissingMarkdownMetadata() throws { + @Test func testErrorForMissingMarkdownMetadata() throws { struct Metadata: WebsiteItemMetadata { let string: String } @@ -32,140 +33,130 @@ final class ErrorTests: PublishTestCase { --- """ - assertErrorThrown( + #expect( + throws: PublishingError( + stepName: "Add Markdown files from 'Content' folder", + path: "one/file.md", + infoMessage: "Missing metadata value for key 'string'" + ) + ) { try generateItem( withMetadataType: Metadata.self, in: .one, fromMarkdown: markdown, fileName: "file.md" - ), - PublishingError( - stepName: "Add Markdown files from 'Content' folder", - path: "one/file.md", - infoMessage: "Missing metadata value for key 'string'" ) - ) + } } - func testErrorForInvalidMarkdownMetadata() throws { + @Test func testErrorForInvalidMarkdownMetadata() throws { let markdown = """ --- audio.url: 🤷‍♂️ --- """ - - assertErrorThrown( + #expect( + throws: PublishingError( + stepName: "Add Markdown files from 'Content' folder", + path: "one/file.md", + infoMessage: "Invalid metadata value for key 'audio.url'" + ) + ) { try generateItem( in: .one, fromMarkdown: markdown, fileName: "file.md" - ), - PublishingError( - stepName: "Add Markdown files from 'Content' folder", - path: "one/file.md", - infoMessage: "Invalid metadata value for key 'audio.url'" ) - ) + } } - func testErrorForThrowingDuringItemMutation() throws { + @Test func testErrorForThrowingDuringItemMutation() throws { struct Error: LocalizedError { var errorDescription: String? { "An error" } } - assertErrorThrown( + #expect(throws: PublishingError( + stepName: "Mutate all items", + path: "one/path/to/item", + infoMessage: "Item mutation failed", + underlyingError: Error() + )) { try publishWebsite(using: [ .addItem(.stub(withPath: "path/to/item")), .mutateAllItems { _ in throw Error() } - ]), - PublishingError( - stepName: "Mutate all items", - path: "one/path/to/item", - infoMessage: "Item mutation failed", - underlyingError: Error() - ) - ) + ]) + } } - func testErrorForMissingPage() throws { - assertErrorThrown( + @Test func testErrorForMissingPage() throws { + #expect(throws: PublishingError( + stepName: "Mutate page at 'invalid/path'", + path: "invalid/path", + infoMessage: "Page not found" + )) { try publishWebsite(using: [ .mutatePage(at: "invalid/path") { _ in } - ]), - PublishingError( - stepName: "Mutate page at 'invalid/path'", - path: "invalid/path", - infoMessage: "Page not found" - ) - ) + ]) + } } - func testErrorForThrowingDuringPageMutation() throws { + @Test func testErrorForThrowingDuringPageMutation() throws { struct Error: LocalizedError { var errorDescription: String? { "An error" } } - assertErrorThrown( + #expect(throws: PublishingError( + stepName: "Mutate all pages", + path: "page", + infoMessage: "Page mutation failed", + underlyingError: Error() + )) { try publishWebsite(using: [ .addPage(.stub(withPath: "page")), .mutateAllPages { _ in throw Error() } - ]), - PublishingError( - stepName: "Mutate all pages", - path: "page", - infoMessage: "Page mutation failed", - underlyingError: Error() - ) - ) + ]) + } } - func testErrorForMissingFolder() throws { - assertErrorThrown( + @Test func testErrorForMissingFolder() throws { + #expect(throws: PublishingError( + stepName: "Copy 'non/existing' files", + path: "non/existing", + infoMessage: "Folder not found" + )) { try publishWebsite(using: [ .copyFiles(at: "non/existing") - ]), - PublishingError( - stepName: "Copy 'non/existing' files", - path: "non/existing", - infoMessage: "Folder not found" - ) - ) + ]) + } } - func testErrorForMissingFile() throws { - assertErrorThrown( + @Test func testErrorForMissingFile() throws { + #expect(throws: PublishingError( + stepName: "Copy file 'non/existing.png'", + path: "non/existing.png", + infoMessage: "File not found" + )) { try publishWebsite(using: [ .copyFile(at: "non/existing.png") - ]), - PublishingError( - stepName: "Copy file 'non/existing.png'", - path: "non/existing.png", - infoMessage: "File not found" - ) - ) + ]) + } } - func testErrorForNoPublishingSteps() throws { - assertErrorThrown( - try publishWebsite(using: []), - PublishingError( - infoMessage: "WebsiteName has no generation steps." - ) - ) - - CommandLine.arguments.append("--deploy") - - assertErrorThrown( - try publishWebsite(using: []), - PublishingError( - infoMessage: "WebsiteName has no deployment steps." - ) - ) + @Test func testErrorForNoPublishingSteps() throws { + #expect(throws: PublishingError( + infoMessage: "WebsiteName has no generation steps." + )) { + try publishWebsite(using: []) + } - CommandLine.arguments.removeLast() + #expect(throws: PublishingError( + infoMessage: "WebsiteName has no deployment steps." + )) { + try publishWebsite(using: [], deploy: true) + } } } diff --git a/Tests/PublishTests/Tests/FileIOTests.swift b/Tests/PublishTests/Tests/FileIOTests.swift index 51c88a23..bb392b6d 100644 --- a/Tests/PublishTests/Tests/FileIOTests.swift +++ b/Tests/PublishTests/Tests/FileIOTests.swift @@ -4,12 +4,14 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing +import Foundation import Publish import Files +import Synchronization -final class FileIOTests: PublishTestCase { - func testCopyingFile() throws { +@Suite("FileIO", .serialized) struct FileIOTests: PublishTestCase { + @Test func testCopyingFile() throws { let folder = try Folder.createTemporary() try folder.createFile(named: "File").write("Hello, world!") @@ -18,10 +20,10 @@ final class FileIOTests: PublishTestCase { ]) let file = try folder.file(at: "Output/File") - XCTAssertEqual(try file.readAsString(), "Hello, world!") + #expect(try file.readAsString() == "Hello, world!") } - func testCopyingFileToSpecificFolder() throws { + @Test func testCopyingFileToSpecificFolder() throws { let folder = try Folder.createTemporary() try folder.createFile(named: "File").write("Hello, world!") @@ -30,10 +32,10 @@ final class FileIOTests: PublishTestCase { ]) let file = try folder.file(at: "Output/Custom/Path/File") - XCTAssertEqual(try file.readAsString(), "Hello, world!") + #expect(try file.readAsString() == "Hello, world!") } - func testCopyingFolder() throws { + @Test func testCopyingFolder() throws { let folder = try Folder.createTemporary() try folder.createSubfolder(named: "Subfolder") @@ -43,10 +45,10 @@ final class FileIOTests: PublishTestCase { } ]) - XCTAssertNotNil(try? folder.subfolder(at: "Output/Subfolder")) + _ = try folder.subfolder(at: "Output/Subfolder") } - func testCopyingResourcesWithFolder() throws { + @Test func testCopyingResourcesWithFolder() throws { let folder = try Folder.createTemporary() let resourcesFolder = try folder.createSubfolder(named: "Resources") try resourcesFolder.createFile(named: "File").write("Hello") @@ -59,11 +61,11 @@ final class FileIOTests: PublishTestCase { let rootFile = try folder.file(at: "Output/Resources/File") let nestedFile = try folder.file(at: "Output/Resources/Subfolder/Nested") - XCTAssertEqual(try rootFile.readAsString(), "Hello") - XCTAssertEqual(try nestedFile.readAsString(), "World!") + #expect(try rootFile.readAsString() == "Hello") + #expect(try nestedFile.readAsString() == "World!") } - func testCopyingResourcesWithoutFolder() throws { + @Test func testCopyingResourcesWithoutFolder() throws { let folder = try Folder.createTemporary() let resourcesFolder = try folder.createSubfolder(named: "Resources") try resourcesFolder.createFile(named: "File").write("Hello") @@ -76,11 +78,11 @@ final class FileIOTests: PublishTestCase { let rootFile = try folder.file(at: "Output/File") let nestedFile = try folder.file(at: "Output/Subfolder/Nested") - XCTAssertEqual(try rootFile.readAsString(), "Hello") - XCTAssertEqual(try nestedFile.readAsString(), "World!") + #expect(try rootFile.readAsString() == "Hello") + #expect(try nestedFile.readAsString() == "World!") } - func testCreatingRootLevelFolder() throws { + @Test func testCreatingRootLevelFolder() throws { let folder = try Folder.createTemporary() try publishWebsite(in: folder, using: [ @@ -90,40 +92,42 @@ final class FileIOTests: PublishTestCase { } ]) - XCTAssertNotNil(try? folder.subfolder(named: "A")) - XCTAssertNotNil(try? folder.file(at: "B/file")) + _ = try folder.subfolder(named: "A") + _ = try folder.file(at: "B/file") } - func testRetrievingOutputFolder() throws { + @Test func testRetrievingOutputFolder() throws { let folder = try Folder.createTemporary() - var firstSectionFolder: Folder? + let firstSectionFolder = Mutex(nil as Folder?) try publishWebsite(in: folder, using: [ .generateHTML(withTheme: .foundation), .step(named: "Get output folder") { context in - firstSectionFolder = try context.outputFolder(at: "one") + try firstSectionFolder.withLock { $0 = try context.outputFolder(at: "one") } } ]) - XCTAssertEqual(firstSectionFolder?.name, "one") + let firstSectionFolderName = firstSectionFolder.withLock(\.?.name) + #expect(firstSectionFolderName == "one") } - func testRetrievingOutputFile() throws { + @Test func testRetrievingOutputFile() throws { let folder = try Folder.createTemporary() - var itemFile: File? + let itemFile = Mutex(nil) try publishWebsite(in: folder, using: [ .addItem(.stub(withPath: "item")), .generateHTML(withTheme: .foundation), .step(named: "Get output file") { context in - itemFile = try context.outputFile(at: "one/item/index.html") + try itemFile.withLock { $0 = try context.outputFile(at: "one/item/index.html") } } ]) - XCTAssertEqual(itemFile?.name, "index.html") + let itemFileName = itemFile.withLock(\.?.name) + #expect(itemFileName == "index.html") } - func testCleaningHiddenFilesInOutputFolder() throws { + @Test func testCleaningHiddenFilesInOutputFolder() throws { let folder = try Folder.createTemporary() try folder.createFile(at: "Output/.hidden") @@ -131,6 +135,6 @@ final class FileIOTests: PublishTestCase { .step(named: "Do nothing") { _ in } ]) - XCTAssertFalse(folder.containsFile(named: "Output/.hidden")) + #expect(folder.containsFile(named: "Output/.hidden") == false) } } diff --git a/Tests/PublishTests/Tests/HTMLGenerationTests.swift b/Tests/PublishTests/Tests/HTMLGenerationTests.swift index 2afca997..9eb23eb2 100644 --- a/Tests/PublishTests/Tests/HTMLGenerationTests.swift +++ b/Tests/PublishTests/Tests/HTMLGenerationTests.swift @@ -4,23 +4,19 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing import Publish import Plot import Files -final class HTMLGenerationTests: PublishTestCase { - private var htmlFactory: HTMLFactoryMock! +@Suite("HTML Generation", .serialized) struct HTMLGenerationTests: PublishTestCase { - override func setUp() { - super.setUp() - htmlFactory = HTMLFactoryMock() - } - - func testGeneratingIndexHTML() throws { - htmlFactory.makeIndexHTML = { content, _ in - HTML(.body(.text(content.title))) - } + @Test func testGeneratingIndexHTML() throws { + let htmlFactory: HTMLFactoryMock = .init( + makeIndexHTML: { content, _ in + HTML(.body(.text(content.title))) + } + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), @@ -29,10 +25,12 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testGeneratingSectionHTML() throws { - htmlFactory.makeSectionHTML = { section, _ in - HTML(.body(.text(section.title))) - } + @Test func testGeneratingSectionHTML() throws { + let htmlFactory: HTMLFactoryMock = .init( + makeSectionHTML: { section, _ in + HTML(.body(.text(section.title))) + } + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), @@ -47,14 +45,16 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testGeneratingItemHTML() throws { - htmlFactory.makeItemHTML = { item, _ in - HTML(.body( - .unwrap(item.audio?.url, { .text($0.absoluteString) }), - .text(" "), - .text(item.title) - )) - } + @Test func testGeneratingItemHTML() throws { + let htmlFactory = HTMLFactoryMock( + makeItemHTML: { item, _ in + HTML(.body( + .unwrap(item.audio?.url, { .text($0.absoluteString) }), + .text(" "), + .text(item.title) + )) + } + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), @@ -79,10 +79,13 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testGeneratingNestedItemHTML() throws { - htmlFactory.makeItemHTML = { item, _ in - HTML(.body(.text(item.title))) - } + @Test func testGeneratingNestedItemHTML() throws { + + let htmlFactory = HTMLFactoryMock( + makeItemHTML:{ item, _ in + HTML(.body(.text(item.title))) + } + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), @@ -101,10 +104,12 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testGeneratingPageHTML() throws { - htmlFactory.makePageHTML = { page, _ in - HTML(.body(.text(page.title))) - } + @Test func testGeneratingPageHTML() throws { + let htmlFactory = HTMLFactoryMock( + makePageHTML:{ page, _ in + HTML(.body(.text(page.title))) + } + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), @@ -126,18 +131,19 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testGeneratingTagHTML() throws { - htmlFactory.makeTagListHTML = { page, _ in - HTML(.body(.ul( - .forEach(page.tags.sorted()) { - .li(.text($0.string)) - } - ))) - } - - htmlFactory.makeTagDetailsHTML = { page, _ in - HTML(.body(.text(page.tag.string))) - } + @Test func testGeneratingTagHTML() throws { + let htmlFactory = HTMLFactoryMock( + makeTagListHTML:{ page, _ in + HTML(.body(.ul( + .forEach(page.tags.sorted()) { + .li(.text($0.string)) + } + ))) + }, + makeTagDetailsHTML:{ page, _ in + HTML(.body(.text(page.tag.string))) + } + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), @@ -166,10 +172,12 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testCleaningUpOldHTMLFiles() throws { - htmlFactory.makePageHTML = { page, _ in - HTML(.body(.text(page.title))) - } + @Test func testCleaningUpOldHTMLFiles() throws { + let htmlFactory = HTMLFactoryMock( + makePageHTML:{ page, _ in + HTML(.body(.text(page.title))) + } + ) let folder = try Folder.createTemporary() @@ -196,10 +204,12 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testAlwaysGeneratingIndexPageForAllSections() throws { - htmlFactory.makeSectionHTML = { section, _ in - HTML(.body(.text(section.id.rawValue))) - } + @Test func testAlwaysGeneratingIndexPageForAllSections() throws { + let htmlFactory = HTMLFactoryMock( + makeSectionHTML:{ section, _ in + HTML(.body(.text(section.id.rawValue))) + } + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), @@ -213,9 +223,11 @@ final class HTMLGenerationTests: PublishTestCase { } - func testNotGeneratingTagHTMLForIncompatibleTheme() throws { - htmlFactory.makeTagListHTML = nil - htmlFactory.makeTagDetailsHTML = nil + @Test func testNotGeneratingTagHTMLForIncompatibleTheme() throws { + let htmlFactory = HTMLFactoryMock( + makeTagListHTML: nil, + makeTagDetailsHTML: nil, + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), @@ -234,9 +246,12 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testNotGeneratingTagHTMLWhenDisabled() throws { - let site = WebsiteStub.WithoutItemMetadata() - site.tagHTMLConfig = nil + @Test func testNotGeneratingTagHTMLWhenDisabled() throws { + let site = WithoutItemMetadata( + tagHTMLConfig: nil + ) + + let htmlFactory = HTMLFactoryMock() try publishWebsite(site, using: Theme(htmlFactory: htmlFactory), @@ -255,7 +270,8 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testGeneratingStandAloneHTMLFiles() throws { + @Test func testGeneratingStandAloneHTMLFiles() throws { + let htmlFactory = HTMLFactoryMock() let folder = try Folder.createTemporary() let theme = Theme(htmlFactory: htmlFactory) @@ -282,7 +298,7 @@ final class HTMLGenerationTests: PublishTestCase { ) } - func testFoundationTheme() throws { + @Test func testFoundationTheme() throws { let folder = try Folder.createTemporary() try publishWebsite( @@ -304,23 +320,23 @@ final class HTMLGenerationTests: PublishTestCase { ) let siteIndex = try folder.file(at: "Output/index.html") - XCTAssertTrue(try siteIndex.readAsString().contains("WebsiteName")) + #expect(try siteIndex.readAsString().contains("WebsiteName")) let sectionIndex = try folder.file(at: "Output/one/index.html") - XCTAssertTrue(try sectionIndex.readAsString().contains("SectionTitle")) + #expect(try sectionIndex.readAsString().contains("SectionTitle")) let item = try folder.file(at: "Output/one/item/index.html") - XCTAssertTrue(try item.readAsString().contains("ItemTitle")) + #expect(try item.readAsString().contains("ItemTitle")) let page = try folder.file(at: "Output/page/index.html") - XCTAssertTrue(try page.readAsString().contains("PageTitle")) + #expect(try page.readAsString().contains("PageTitle")) let tagList = try folder.file(at: "Output/tags/index.html") let tagListHTML = try tagList.readAsString() - XCTAssertTrue(tagListHTML.contains("tagA")) - XCTAssertTrue(tagListHTML.contains("tagB")) + #expect(tagListHTML.contains("tagA")) + #expect(tagListHTML.contains("tagB")) let tagDetails = try folder.file(at: "Output/tags/taga/index.html") - XCTAssertTrue(try tagDetails.readAsString().contains("tagA")) + #expect(try tagDetails.readAsString().contains("tagA")) } } diff --git a/Tests/PublishTests/Tests/MarkdownTests.swift b/Tests/PublishTests/Tests/MarkdownTests.swift index 28df7927..72e97baa 100644 --- a/Tests/PublishTests/Tests/MarkdownTests.swift +++ b/Tests/PublishTests/Tests/MarkdownTests.swift @@ -4,18 +4,19 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing +import Foundation +import Synchronization import Files -import Ink import Publish -final class MarkdownTests: PublishTestCase { - func testParsingFileWithTitle() throws { +@Suite("Markdown", .serialized) struct MarkdownTests: PublishTestCase { + @Test func testParsingFileWithTitle() throws { let item = try generateItem(fromMarkdown: "# Title") - XCTAssertEqual(item.title, "Title") + #expect(item.title == "Title") } - func testParsingFileWithOverriddenTitle() throws { + @Test func testParsingFileWithOverriddenTitle() throws { let item = try generateItem(fromMarkdown: """ --- title: Overridden title @@ -23,10 +24,10 @@ final class MarkdownTests: PublishTestCase { # Title """) - XCTAssertEqual(item.title, "Overridden title") + #expect(item.title == "Overridden title") } - func testParsingFileWithNoTitle() throws { + @Test func testParsingFileWithNoTitle() throws { let item = try generateItem(fromMarkdown: """ --- description: A description @@ -34,20 +35,20 @@ final class MarkdownTests: PublishTestCase { No title here """, fileName: "fallback.md") - XCTAssertEqual(item.title, "fallback") + #expect(item.title == "fallback") } - func testParsingFileWithOverriddenPath() throws { + @Test func testParsingFileWithOverriddenPath() throws { let item = try generateItem(fromMarkdown: """ --- path: overridden-path --- """) - XCTAssertEqual(item.path, "one/overridden-path") + #expect(item.path == "one/overridden-path") } - func testParsingFileWithBuiltInMetadata() throws { + @Test func testParsingFileWithBuiltInMetadata() throws { let item = try generateItem(fromMarkdown: """ --- description: Description @@ -68,16 +69,16 @@ final class MarkdownTests: PublishTestCase { expectedDateComponents.hour = 10 expectedDateComponents.minute = 30 - XCTAssertEqual(item.description, "Description") - XCTAssertEqual(item.tags, ["One", "Two", "Three"]) - XCTAssertEqual(item.imagePath, "myImage.png") - XCTAssertEqual(item.date, expectedDateComponents.date) - XCTAssertEqual(item.audio?.url, URL(string: "https://myFile.mp3")) - XCTAssertEqual(item.audio?.duration, Audio.Duration(hours: 1, minutes: 3, seconds: 5)) - XCTAssertEqual(item.video, .youTube(id: "12345")) + #expect(item.description == "Description") + #expect(item.tags == ["One", "Two", "Three"]) + #expect(item.imagePath == "myImage.png") + #expect(item.date == expectedDateComponents.date) + #expect(item.audio?.url == URL(string: "https://myFile.mp3")) + #expect(item.audio?.duration == Audio.Duration(hours: 1, minutes: 3, seconds: 5)) + #expect(item.video == .youTube(id: "12345")) } - func testParsingFileWithCustomMetadata() throws { + @Test func testParsingFileWithCustomMetadata() throws { struct Metadata: WebsiteItemMetadata { struct Nested: WebsiteItemMetadata { var string: String @@ -113,18 +114,18 @@ final class MarkdownTests: PublishTestCase { let expectedURLs = ["https://a.url", "https://b.url"].compactMap(URL.init) - XCTAssertEqual(item.metadata.string, "Hello, world!") - XCTAssertEqual(item.metadata.url, URL(string: "https://url.com")) - XCTAssertEqual(item.metadata.int, 42) - XCTAssertEqual(item.metadata.double, 3.14) - XCTAssertEqual(item.metadata.stringArray, ["One", "Two", "Three"]) - XCTAssertEqual(item.metadata.urlArray, expectedURLs) - XCTAssertEqual(item.metadata.intArray, [1, 2, 3]) - XCTAssertEqual(item.metadata.nested.string, "I'm nested!") - XCTAssertEqual(item.metadata.nested.url, URL(string: "https://nested.url")) + #expect(item.metadata.string == "Hello, world!") + #expect(item.metadata.url == URL(string: "https://url.com")) + #expect(item.metadata.int == 42) + #expect(item.metadata.double == 3.14) + #expect(item.metadata.stringArray == ["One", "Two", "Three"]) + #expect(item.metadata.urlArray == expectedURLs) + #expect(item.metadata.intArray == [1, 2, 3]) + #expect(item.metadata.nested.string == "I'm nested!") + #expect(item.metadata.nested.url == URL(string: "https://nested.url")) } - func testParsingPageInNestedFolder() throws { + @Test func testParsingPageInNestedFolder() throws { let folder = try Folder.createTemporary() let pageFile = try folder.createFile(at: "Content/my/custom/page.md") try pageFile.write("# MyPage") @@ -133,10 +134,10 @@ final class MarkdownTests: PublishTestCase { .addMarkdownFiles() ]) - XCTAssertEqual(site.pages["my/custom/page"]?.title, "MyPage") + #expect(site.pages["my/custom/page"]?.title == "MyPage") } - func testNotParsingNonMarkdownFiles() throws { + @Test func testNotParsingNonMarkdownFiles() throws { let folder = try Folder.createTemporary() try folder.createFile(at: "Content/image.png") try folder.createFile(at: "Content/one/image.png") @@ -146,7 +147,7 @@ final class MarkdownTests: PublishTestCase { .addMarkdownFiles() ]) - XCTAssertEqual(site.pages, [:]) - XCTAssertEqual(site.sections[.one].items, []) + #expect(site.pages == [:]) + #expect(site.sections[.one].items == []) } } diff --git a/Tests/PublishTests/Tests/PathTests.swift b/Tests/PublishTests/Tests/PathTests.swift index 4263e812..d184ab65 100644 --- a/Tests/PublishTests/Tests/PathTests.swift +++ b/Tests/PublishTests/Tests/PathTests.swift @@ -4,33 +4,35 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing +import Foundation +import Synchronization import Publish import Codextended -final class PathTests: PublishTestCase { - func testAbsoluteString() { - XCTAssertEqual(Path("relative").absoluteString, "/relative") - XCTAssertEqual(Path("/absolute").absoluteString, "/absolute") +@Suite("Path", .serialized) struct PathTests: PublishTestCase { + @Test func testAbsoluteString() { + #expect(Path("relative").absoluteString == "/relative") + #expect(Path("/absolute").absoluteString == "/absolute") } - func testAppendingComponent() { + @Test func testAppendingComponent() { let path = Path("one") - XCTAssertEqual(path.appendingComponent("two"), "one/two") + #expect(path.appendingComponent("two") == "one/two") } - func testStringInterpolation() { + @Test func testStringInterpolation() { let path = Path("my/path") - XCTAssertEqual("\(path)", "my/path") + #expect("\(path)" == "my/path") } - func testCoding() throws { + @Test func testCoding() throws { struct Wrapper: Equatable, Codable { let path: Path } let wrapper = Wrapper(path: Path("my/path")) let data = try wrapper.encoded() - XCTAssertEqual(wrapper, try data.decoded()) + #expect(try wrapper == data.decoded()) } } diff --git a/Tests/PublishTests/Tests/PlotComponentTests.swift b/Tests/PublishTests/Tests/PlotComponentTests.swift index 5269da15..208c7a1c 100644 --- a/Tests/PublishTests/Tests/PlotComponentTests.swift +++ b/Tests/PublishTests/Tests/PlotComponentTests.swift @@ -4,16 +4,17 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing +import Foundation +import Synchronization import Publish import Plot -import Ink -final class PlotComponentTests: PublishTestCase { - func testStylesheetPaths() { +@Suite("PlotComponent", .serialized) struct PlotComponentTests: PublishTestCase { + @Test func testStylesheetPaths() { let html = Node.head( for: Page(path: "path", content: Content()), - on: WebsiteStub.WithoutItemMetadata(), + on: WithoutItemMetadata(), stylesheetPaths: [ "local-1.css", "/local-2.css", @@ -30,75 +31,76 @@ final class PlotComponentTests: PublishTestCase { ] for url in expectedURLs { - XCTAssertTrue(html.contains(""" + #expect(html.contains(""" """)) } } - func testRenderingAudioPlayer() throws { + @Test func testRenderingAudioPlayer() throws { let url = try require(URL(string: "https://audio.mp3")) let audio = Audio(url: url, format: .mp3) let html = Node.audioPlayer(for: audio).render() - XCTAssertEqual(html, """ + #expect(html == """ """) } - func testRenderingHostedVideoPlayer() throws { + @Test func testRenderingHostedVideoPlayer() throws { let url = try require(URL(string: "https://video.mp4")) let video = Video.hosted(url: url, format: .mp4) let html = Node.videoPlayer(for: video).render() - XCTAssertEqual(html, """ + #expect(html == """ """) } - func testRenderingYouTubeVideoPlayer() { + @Test func testRenderingYouTubeVideoPlayer() { let video = Video.youTube(id: "123") let html = Node.videoPlayer(for: video).render() - XCTAssertEqual(html, """ + #expect(html == """ """) } - func testRenderingVimeoVideoPlayer() { + @Test func testRenderingVimeoVideoPlayer() { let video = Video.vimeo(id: "123") let html = Node.videoPlayer(for: video).render() - XCTAssertEqual(html, """ + #expect(html == """ """) } - func testRenderingMarkdownComponent() { - let customParser = MarkdownParser(modifiers: [ - Modifier(target: .links) { html, _ in - return "\(html)" - } - ]) + @Test func testRenderingMarkdownComponent() { + var customParser = MarkdownParser() + customParser.addModifier(for: .link) { html, _, _ in + return .b(html) + } + + let parser = customParser let html = Div { - Markdown("[First](/first)") + MarkdownComponent("[First](/first)") Div { - Markdown("[Second](/second)") + MarkdownComponent("[Second](/second)") } - .markdownParser(customParser) + .markdownParser(parser) } .render() - XCTAssertEqual(html, """ + #expect(html == """
\

First

\ \ diff --git a/Tests/PublishTests/Tests/PluginTests.swift b/Tests/PublishTests/Tests/PluginTests.swift index 14ead1eb..e538b8ab 100644 --- a/Tests/PublishTests/Tests/PluginTests.swift +++ b/Tests/PublishTests/Tests/PluginTests.swift @@ -4,31 +4,29 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing +import Synchronization +import Foundation import Publish import Plot -import Ink -final class PluginTests: PublishTestCase { - func testAddingContentUsingPlugin() throws { +@Suite("Plugin", .serialized) struct PluginTests: PublishTestCase { + @Test func testAddingContentUsingPlugin() throws { let site = try publishWebsite(using: [ .installPlugin(Plugin(name: "Plugin") { context in context.addItem(.stub()) }) ]) - XCTAssertEqual(site.sections[.one].items.count, 1) + #expect(site.sections[.one].items.count == 1) } - func testAddingInkModifierUsingPlugin() throws { + @Test func testAddingInkModifierUsingPlugin() throws { let site = try publishWebsite(using: [ .installPlugin(Plugin(name: "Plugin") { context in - context.markdownParser.addModifier(Modifier( - target: .paragraphs, - closure: { html, _ in - "
\(html)
" - } - )) + context.addModifier(for: .paragraph) { html, document, markup in + .div(html) + } }), .addMarkdownFiles() ], content: [ @@ -36,27 +34,25 @@ final class PluginTests: PublishTestCase { ]) let items = site.sections[.one].items - XCTAssertEqual(items.count, 1) - XCTAssertEqual(items.first?.path, "one/a") - XCTAssertEqual(items.first?.body.html, "

Hello

") + #expect(items.count == 1) + #expect(items.first?.path == "one/a") + #expect(items.first?.body.html == "

Hello

") } - func testAddingPluginToDefaultPipeline() throws { - let htmlFactory = HTMLFactoryMock() - htmlFactory.makeIndexHTML = { content, _ in - HTML(.body(content.body.node)) - } + @Test func testAddingPluginToDefaultPipeline() throws { + let htmlFactory = HTMLFactoryMock( + makeIndexHTML: { content, _ in + HTML(.body(content.body.node)) + } + ) try publishWebsite( using: Theme(htmlFactory: htmlFactory), content: ["index.md": "Hello, World!"], plugins: [Plugin(name: "Plugin") { context in - context.markdownParser.addModifier(Modifier( - target: .paragraphs, - closure: { html, _ in - "
\(html)
" - } - )) + context.addModifier(for: .paragraph) { html, document, markup in + .section(html) + } }], expectedHTML: ["index.html": "

Hello, World!

"] ) diff --git a/Tests/PublishTests/Tests/PodcastFeedGenerationTests.swift b/Tests/PublishTests/Tests/PodcastFeedGenerationTests.swift index e836f52d..cdd94106 100644 --- a/Tests/PublishTests/Tests/PodcastFeedGenerationTests.swift +++ b/Tests/PublishTests/Tests/PodcastFeedGenerationTests.swift @@ -4,12 +4,14 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing +import Foundation +import Synchronization import Publish import Files -final class PodcastFeedGenerationTests: PublishTestCase { - func testOnlyIncludingSpecifiedSection() throws { +@Suite("PodcastFeedGeneration", .serialized) struct PodcastFeedGenerationTests: PublishTestCase { + @Test func testOnlyIncludingSpecifiedSection() throws { let folder = try Folder.createTemporary() try generateFeed(in: folder, content: [ @@ -21,11 +23,11 @@ final class PodcastFeedGenerationTests: PublishTestCase { ]) let feed = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertTrue(feed.contains("Included")) - XCTAssertFalse(feed.contains("Not included")) + #expect(feed.contains("Included")) + #expect(!feed.contains("Not included")) } - func testOnlyIncludingItemsMatchingPredicate() throws { + @Test func testOnlyIncludingItemsMatchingPredicate() throws { let folder = try Folder.createTemporary() try generateFeed( @@ -41,11 +43,11 @@ final class PodcastFeedGenerationTests: PublishTestCase { ) let feed = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertTrue(feed.contains("Included")) - XCTAssertFalse(feed.contains("Not included")) + #expect(feed.contains("Included")) + #expect(!feed.contains("Not included")) } - func testConvertingRelativeLinksToAbsolute() throws { + @Test func testConvertingRelativeLinksToAbsolute() throws { let folder = try Folder.createTemporary() try generateFeed(in: folder, content: [ @@ -58,14 +60,14 @@ final class PodcastFeedGenerationTests: PublishTestCase { let feed = try folder.file(at: "Output/feed.rss").readAsString() let substring = feed.substrings(between: "BEGIN ", and: " END").first - XCTAssertEqual(substring, """ + #expect(substring == """ Link \ \"Image\"/ \ Link """) } - func testItemPrefixAndSuffix() throws { + @Test func testItemPrefixAndSuffix() throws { let folder = try Folder.createTemporary() let prefixSuffix = """ @@ -81,10 +83,10 @@ final class PodcastFeedGenerationTests: PublishTestCase { ]) let feed = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertTrue(feed.contains("PrefixTitleSuffix")) + #expect(feed.contains("PrefixTitleSuffix")) } - func testReusingPreviousFeedIfNoItemsWereModified() throws { + @Test func testReusingPreviousFeedIfNoItemsWereModified() throws { let folder = try Folder.createTemporary() let contentFile = try folder.createFile(at: "Content/one/item.md") try contentFile.write(makeStubbedAudioMetadata()) @@ -96,16 +98,16 @@ final class PodcastFeedGenerationTests: PublishTestCase { try generateFeed(in: folder, date: newDate) let feedB = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertEqual(feedA, feedB) + #expect(feedA == feedB) try contentFile.append("New content") try generateFeed(in: folder, date: newDate) let feedC = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertNotEqual(feedB, feedC) + #expect(feedB != feedC) } - func testNotReusingPreviousFeedIfConfigChanged() throws { + @Test func testNotReusingPreviousFeedIfConfigChanged() throws { let folder = try Folder.createTemporary() let contentFile = try folder.createFile(at: "Content/one/item.md") try contentFile.write(makeStubbedAudioMetadata()) @@ -119,10 +121,10 @@ final class PodcastFeedGenerationTests: PublishTestCase { try generateFeed(in: folder, config: newConfig, date: newDate) let feedB = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertNotEqual(feedA, feedB) + #expect(feedA != feedB) } - func testNotReusingPreviousFeedIfItemWasAdded() throws { + @Test func testNotReusingPreviousFeedIfItemWasAdded() throws { let folder = try Folder.createTemporary() let audio = try Audio( @@ -160,12 +162,12 @@ final class PodcastFeedGenerationTests: PublishTestCase { ]) let feedB = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertNotEqual(feedA, feedB) + #expect(feedA != feedB) } } private extension PodcastFeedGenerationTests { - typealias Site = WebsiteStub.WithPodcastMetadata + typealias Site = WithPodcastMetadata typealias Configuration = PodcastFeedConfiguration func makeConfigStub() throws -> Configuration { @@ -197,7 +199,7 @@ private extension PodcastFeedGenerationTests { func generateFeed( in folder: Folder, config: Configuration? = nil, - itemPredicate: Predicate>? = nil, + itemPredicate: Publish.Predicate>? = nil, generationSteps: [PublishingStep] = [ .addMarkdownFiles() ], diff --git a/Tests/PublishTests/Tests/PublishingContextTests.swift b/Tests/PublishTests/Tests/PublishingContextTests.swift index 4a92583e..4687b3dc 100644 --- a/Tests/PublishTests/Tests/PublishingContextTests.swift +++ b/Tests/PublishTests/Tests/PublishingContextTests.swift @@ -4,22 +4,24 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing import Publish +import Synchronization -final class PublishingContextTests: PublishTestCase { - func testSectionIterationOrder() throws { - let expectedOrder = WebsiteStub.SectionID.allCases - var actualOrder = [WebsiteStub.SectionID]() +@Suite("PublishingContext", .serialized) struct PublishingContextTests: PublishTestCase { + @Test func testSectionIterationOrder() throws { + let expectedOrder = WithoutItemMetadata.SectionID.allCases + let actualOrder = Mutex([WithoutItemMetadata.SectionID]()) try publishWebsite(using: [ .step(named: "Step") { context in context.sections.forEach { section in - actualOrder.append(section.id) + actualOrder.withLock { $0.append(section.id) } } } ]) - XCTAssertEqual(expectedOrder, actualOrder) + let order = actualOrder.withLock( \.self ) + #expect(expectedOrder == order) } } diff --git a/Tests/PublishTests/Tests/RSSFeedGenerationTests.swift b/Tests/PublishTests/Tests/RSSFeedGenerationTests.swift index 52cd192d..53b6a858 100644 --- a/Tests/PublishTests/Tests/RSSFeedGenerationTests.swift +++ b/Tests/PublishTests/Tests/RSSFeedGenerationTests.swift @@ -4,13 +4,15 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing +import Synchronization +import Foundation import Publish import Files import Sweep -final class RSSFeedGenerationTests: PublishTestCase { - func testOnlyIncludingSpecifiedSections() throws { +@Suite("RSSFeedGeneration", .serialized) struct RSSFeedGenerationTests: PublishTestCase { + @Test func testOnlyIncludingSpecifiedSections() throws { let folder = try Folder.createTemporary() try generateFeed(in: folder, content: [ @@ -19,11 +21,11 @@ final class RSSFeedGenerationTests: PublishTestCase { ]) let feed = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertTrue(feed.contains("Included")) - XCTAssertFalse(feed.contains("Not included")) + #expect(feed.contains("Included")) + #expect(!feed.contains("Not included")) } - func testOnlyIncludingItemsMatchingPredicate() throws { + @Test func testOnlyIncludingItemsMatchingPredicate() throws { let folder = try Folder.createTemporary() try generateFeed( @@ -36,11 +38,11 @@ final class RSSFeedGenerationTests: PublishTestCase { ) let feed = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertTrue(feed.contains("Included")) - XCTAssertFalse(feed.contains("Not included")) + #expect(feed.contains("Included")) + #expect(!feed.contains("Not included")) } - func testConvertingRelativeLinksToAbsolute() throws { + @Test func testConvertingRelativeLinksToAbsolute() throws { let folder = try Folder.createTemporary() try generateFeed(in: folder, content: [ @@ -52,14 +54,14 @@ final class RSSFeedGenerationTests: PublishTestCase { let feed = try folder.file(at: "Output/feed.rss").readAsString() let substring = feed.firstSubstring(between: "BEGIN ", and: " END") - XCTAssertEqual(substring, """ + #expect(substring == """ Link \ \"Image\"/ \ Link """) } - func testItemTitlePrefixAndSuffix() throws { + @Test func testItemTitlePrefixAndSuffix() throws { let folder = try Folder.createTemporary() try generateFeed(in: folder, content: [ @@ -73,10 +75,10 @@ final class RSSFeedGenerationTests: PublishTestCase { ]) let feed = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertTrue(feed.contains("PrefixTitleSuffix")) + #expect(feed.contains("PrefixTitleSuffix")) } - func testItemBodyPrefixAndSuffix() throws { + @Test func testItemBodyPrefixAndSuffix() throws { let folder = try Folder.createTemporary() try generateFeed(in: folder, content: [ @@ -91,12 +93,12 @@ final class RSSFeedGenerationTests: PublishTestCase { let feed = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertTrue(feed.contains(""" + #expect(feed.contains(""" Body

Suffix]]>
""")) } - func testCustomItemLink() throws { + @Test func testCustomItemLink() throws { let folder = try Folder.createTemporary() try generateFeed(in: folder, content: [ @@ -110,14 +112,14 @@ final class RSSFeedGenerationTests: PublishTestCase { let feed = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertTrue(feed.contains("custom.link")) + #expect(feed.contains("custom.link")) - XCTAssertTrue(feed.contains(""" + #expect(feed.contains(""" https://swiftbysundell.com/one/item """)) } - func testReusingPreviousFeedIfNoItemsWereModified() throws { + @Test func testReusingPreviousFeedIfNoItemsWereModified() throws { let folder = try Folder.createTemporary() let contentFile = try folder.createFile(at: "Content/one/item.md") @@ -128,16 +130,16 @@ final class RSSFeedGenerationTests: PublishTestCase { try generateFeed(in: folder, date: newDate) let feedB = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertEqual(feedA, feedB) + #expect(feedA == feedB) try contentFile.append("New content") try generateFeed(in: folder, date: newDate) let feedC = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertNotEqual(feedB, feedC) + #expect(feedB != feedC) } - func testNotReusingPreviousFeedIfConfigChanged() throws { + @Test func testNotReusingPreviousFeedIfConfigChanged() throws { let folder = try Folder.createTemporary() try folder.createFile(at: "Content/one/item.md") @@ -149,10 +151,10 @@ final class RSSFeedGenerationTests: PublishTestCase { try generateFeed(in: folder, config: newConfig, date: newDate) let feedB = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertNotEqual(feedA, feedB) + #expect(feedA != feedB) } - func testNotReusingPreviousFeedIfItemWasAdded() throws { + @Test func testNotReusingPreviousFeedIfItemWasAdded() throws { let folder = try Folder.createTemporary() let itemA = Item.stub() let itemB = Item.stub().setting(\.lastModified, to: itemA.lastModified) @@ -169,17 +171,17 @@ final class RSSFeedGenerationTests: PublishTestCase { ]) let feedB = try folder.file(at: "Output/feed.rss").readAsString() - XCTAssertNotEqual(feedA, feedB) + #expect(feedA != feedB) } } private extension RSSFeedGenerationTests { - typealias Site = WebsiteStub.WithoutItemMetadata + typealias Site = WithoutItemMetadata func generateFeed( in folder: Folder, config: RSSFeedConfiguration = .default, - itemPredicate: Predicate>? = nil, + itemPredicate: Publish.Predicate>? = nil, generationSteps: [PublishingStep] = [ .addMarkdownFiles() ], diff --git a/Tests/PublishTests/Tests/SiteMapGenerationTests.swift b/Tests/PublishTests/Tests/SiteMapGenerationTests.swift index c60cd126..c1451720 100644 --- a/Tests/PublishTests/Tests/SiteMapGenerationTests.swift +++ b/Tests/PublishTests/Tests/SiteMapGenerationTests.swift @@ -4,12 +4,12 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing import Publish import Files -final class SiteMapGenerationTests: PublishTestCase { - func testGeneratingSiteMap() throws { +@Suite("SiteMapGeneration", .serialized) struct SiteMapGenerationTests: PublishTestCase { + @Test func testGeneratingSiteMap() throws { let folder = try Folder.createTemporary() try publishWebsite(in: folder, using: [ @@ -28,11 +28,11 @@ final class SiteMapGenerationTests: PublishTestCase { ] for location in expectedLocations { - XCTAssertTrue(siteMap.contains("\(location)")) + #expect(siteMap.contains("\(location)")) } } - func testExcludingPathsFromSiteMap() throws { + @Test func testExcludingPathsFromSiteMap() throws { let folder = try Folder.createTemporary() let site = try publishWebsite(in: folder, using: [ @@ -72,17 +72,17 @@ final class SiteMapGenerationTests: PublishTestCase { ] for location in expectedLocations { - XCTAssertTrue(siteMap.contains("\(location)")) + #expect(siteMap.contains("\(location)")) } for location in unexpectedLocations { - XCTAssertFalse(siteMap.contains("\(location)")) + #expect(!siteMap.contains("\(location)")) } - XCTAssertNotNil(site.sections[.one].item(at: "itemB")) - XCTAssertNotNil(site.sections[.two].item(at: "itemC")) - XCTAssertNotNil(site.sections[.two].item(at: "itemD")) - XCTAssertNotNil(site.sections[.three].item(at: "itemE")) - XCTAssertNotNil(site.pages["pageB"]) + #expect(site.sections[.one].item(at: "itemB") != nil) + #expect(site.sections[.two].item(at: "itemC") != nil) + #expect(site.sections[.two].item(at: "itemD") != nil) + #expect(site.sections[.three].item(at: "itemE") != nil) + #expect(site.pages["pageB"] != nil) } } diff --git a/Tests/PublishTests/Tests/WebsiteTests.swift b/Tests/PublishTests/Tests/WebsiteTests.swift index 1ab15744..b4bd5e4f 100644 --- a/Tests/PublishTests/Tests/WebsiteTests.swift +++ b/Tests/PublishTests/Tests/WebsiteTests.swift @@ -4,80 +4,76 @@ * MIT license, see LICENSE file for details */ -import XCTest +import Testing import Publish +import Foundation -final class WebsiteTests: PublishTestCase { - private var website: WebsiteStub.WithoutItemMetadata! +@Suite("Website", .serialized) struct WebsiteTests: PublishTestCase { + var website: WithoutItemMetadata = .init() - override func setUp() { - super.setUp() - website = .init() + @Test func testDefaultTagListPath() { + #expect(WithoutItemMetadata().tagListPath == "tags") } - func testDefaultTagListPath() { - XCTAssertEqual(website.tagListPath, "tags") - } - - func testCustomTagListPath() { + @Test mutating func testCustomTagListPath() { website.tagHTMLConfig = TagHTMLConfiguration(basePath: "custom") - XCTAssertEqual(website.tagListPath, "custom") + #expect(website.tagListPath == "custom") } - func testPathForSectionID() { - XCTAssertEqual(website.path(for: .one), "one") + @Test func testPathForSectionID() { + #expect(website.path(for: .one) == "one") } - func testPathForSectionIDWithRawValue() { - XCTAssertEqual(website.path(for: .customRawValue), "custom-raw-value") + @Test func testPathForSectionIDWithRawValue() { + #expect(website.path(for: .customRawValue) == "custom-raw-value") } - func testDefaultPathForTag() { + @Test func testDefaultPathForTag() { let tag = Tag("some tag") - XCTAssertEqual(website.path(for: tag), "tags/some-tag") + #expect(website.path(for: tag) == "tags/some-tag") } - func testCustomPathForTag() { + @Test mutating func testCustomPathForTag() { website.tagHTMLConfig = TagHTMLConfiguration(basePath: "custom") let tag = Tag("some tag") - XCTAssertEqual(website.path(for: tag), "custom/some-tag") + #expect(website.path(for: tag) == "custom/some-tag") } - func testDefaultURLForTag() { - XCTAssertEqual( - website.url(for: Tag("some tag")), + @Test func testDefaultURLForTag() { + #expect( + website.url(for: Tag("some tag")) == URL(string: "https://swiftbysundell.com/tags/some-tag") ) } - func testCustomURLForTag() { + @Test mutating func testCustomURLForTag() { website.tagHTMLConfig = TagHTMLConfiguration(basePath: "custom") - XCTAssertEqual( - website.url(for: Tag("some tag")), + #expect( + website.url(for: Tag("some tag")) == URL(string: "https://swiftbysundell.com/custom/some-tag") ) } - func testURLForRelativePath() { - XCTAssertEqual( - website.url(for: Path("a/path")), + @Test func testURLForRelativePath() { + #expect( + website.url(for: Path("a/path")) == URL(string: "https://swiftbysundell.com/a/path") ) } - func testURLForAbsolutePath() { - XCTAssertEqual( - website.url(for: Path("/a/path")), + @Test func testURLForAbsolutePath() { + #expect( + website.url(for: Path("/a/path")) == URL(string: "https://swiftbysundell.com/a/path") ) } - func testURLForLocation() { + @Test func testURLForLocation() { let page = Page(path: "mypage", content: Content()) - XCTAssertEqual( - website.url(for: page), + #expect( + website.url(for: page) == URL(string: "https://swiftbysundell.com/mypage") ) }