├── .gitignore ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── StaticSite │ ├── AssetHashing.swift │ ├── AtomFeed.swift │ ├── CopyRule.swift │ ├── EnvironmentValues.swift │ ├── ForEach.swift │ ├── MarkdownToSwim.swift │ ├── Measure.swift │ ├── ParseHelpers.swift │ ├── Rule.swift │ ├── Template.swift │ ├── URLReplacer.swift │ ├── WriteNode.swift │ └── YamlWithFrontMatter.swift ├── Tests └── StaticSiteTests │ └── EnvironmentTests.swift └── docker.sh /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "cmark-gfm", 6 | "repositoryURL": "https://github.com/apple/swift-cmark.git", 7 | "state": { 8 | "branch": "gfm", 9 | "revision": "bfdc057b5a02fc65af20771a7ba08f9c944eb117", 10 | "version": null 11 | } 12 | }, 13 | { 14 | "package": "swift-crypto", 15 | "repositoryURL": "https://github.com/apple/swift-crypto.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "3bea268b223651c4ab7b7b9ad62ef9b2d4143eb6", 19 | "version": "1.1.6" 20 | } 21 | }, 22 | { 23 | "package": "swift-markdown", 24 | "repositoryURL": "https://github.com/apple/swift-markdown", 25 | "state": { 26 | "branch": "main", 27 | "revision": "87ae1a8fa9180b85630c7b41ddd5aa40ffc87ce3", 28 | "version": null 29 | } 30 | }, 31 | { 32 | "package": "HTML", 33 | "repositoryURL": "https://github.com/robb/Swim.git", 34 | "state": { 35 | "branch": "main", 36 | "revision": "d3dde8cf781507ada3b7549996bea94f8d57181f", 37 | "version": null 38 | } 39 | } 40 | ] 41 | }, 42 | "version": 1 43 | } 44 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | // Todo we could only use swift-crypto on linux with a conditional dependency 7 | 8 | let package = Package( 9 | name: "StaticSite", 10 | platforms: [ 11 | .macOS(.v10_15) 12 | ], 13 | products: [ 14 | .library( 15 | name: "StaticSite", 16 | targets: ["StaticSite"]), 17 | ], 18 | dependencies: [ 19 | .package(name: "HTML", url: "https://github.com/robb/Swim.git", .branch("main")), 20 | .package(url: "https://github.com/apple/swift-markdown", .branch("main")), 21 | .package(name: "swift-crypto", url: "https://github.com/apple/swift-crypto.git", from: "1.1.6"), 22 | ], 23 | targets: [ 24 | 25 | .target( 26 | name: "StaticSite", 27 | dependencies: [ 28 | .product(name: "Markdown", package: "swift-markdown"), 29 | .product(name: "Swim", package: "HTML"), 30 | .product(name: "HTML", package: "HTML"), 31 | .product(name: "Crypto", package: "swift-crypto"), 32 | 33 | ]), 34 | .testTarget( 35 | name: "StaticSiteTests", 36 | dependencies: ["StaticSite"]), 37 | ] 38 | ) 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # StaticSite 2 | 3 | This contains a bunch of helper functions to generate a static site in Swift. 4 | 5 | We use this library to generate and . We would not recommend depending on this, as the library is going to change whenever we need it. Ultimately, we would not mind creating an actual library that you can depend on, but we didn't have the time for that just yet. 6 | 7 | Here's a sample site that uses this: [chriseidhof/chriseidhofnl](https://github.com/chriseidhof/chriseidhofnl). 8 | 9 | Here are some things we'd like to do: 10 | 11 | - [ ] A short explanation of the `Rule` type with some examples 12 | - [ ] Full documentation 13 | -------------------------------------------------------------------------------- /Sources/StaticSite/AssetHashing.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 21.06.21. 6 | // 7 | 8 | import Foundation 9 | import Swim 10 | #if os(Linux) 11 | import Crypto 12 | #else 13 | import CryptoKit 14 | #endif 15 | 16 | extension FileManager { 17 | func allFiles(at: URL) -> [String] { 18 | var result: [String] = [] 19 | allFiles_(at: at, prefix: "", result: &result) 20 | return result 21 | } 22 | private func allFiles_(at: URL, prefix: String, result: inout [String]) { 23 | var isDir: ObjCBool = false 24 | guard fileExists(atPath: at.path, isDirectory: &isDir) else { return } 25 | if isDir.boolValue { 26 | let p = prefix.appending("/" + at.lastPathComponent) 27 | if let files = try? contentsOfDirectory(atPath: at.path) { 28 | for c in files { 29 | allFiles_(at: at.appendingPathComponent(c), prefix: p, result: &result) 30 | } 31 | } 32 | } else { 33 | result.append(prefix + "/" + at.lastPathComponent) 34 | } 35 | } 36 | } 37 | 38 | // TODO I think this could be parallelized 39 | public func hashAssetNames(source: String, environment: EnvironmentValues) -> [String:String] { 40 | let root = environment.inputBaseURL 41 | let allFiles = FileManager.default.allFiles(at: root.appendingPathComponent(source)) 42 | var result: [String:String] = [:] 43 | for input in allFiles { 44 | let data = try! Data(contentsOf: root.appendingPathComponent(input)) 45 | let shaString = "_" + Insecure.SHA1.hash(data: data).map { String(format: "%02hhx", $0) }.joined() 46 | var copy = input 47 | if let dot = copy.lastIndex(of: ".") { 48 | copy.insert(contentsOf: shaString.prefix(8), at: dot) 49 | } else { 50 | fatalError("\(copy) does not contain an extension, cannot hash.") 51 | } 52 | result[input] = copy 53 | } 54 | return result 55 | } 56 | 57 | extension Node { 58 | public func withHashedAssets(_ assets: [String:String]) -> Node { 59 | AssetHasher(assets: assets).visitNode(self) 60 | } 61 | } 62 | 63 | let urlProperties: Set = [ 64 | "src", 65 | "href", 66 | "url", 67 | "action", 68 | "srcset", 69 | ] 70 | 71 | struct AssetHasher: Visitor { 72 | typealias Result = Node 73 | var assets: [String:String] 74 | 75 | func visitRaw(raw: String) -> Node { 76 | return .raw(raw) 77 | } 78 | 79 | func visitElement(name: String, attributes: [String : String], child: Node?) -> Node { 80 | guard attributes.keys.contains(where: { urlProperties.contains($0) }) else { 81 | return .element(name, attributes, child.map(visitNode)) 82 | } 83 | var new: [String:String] = [:] 84 | for (key, value) in attributes { 85 | var newValue = value 86 | if urlProperties.contains(key) { 87 | if key == "srcset" { 88 | newValue = value.split(whereSeparator: { $0.isWhitespace}).map { 89 | assets[String($0)] ?? String($0) 90 | }.joined(separator: " ") 91 | } else { 92 | newValue = assets[value] ?? newValue 93 | } 94 | } 95 | new[key] = newValue 96 | } 97 | return .element(name, new, child.map(visitNode)) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/StaticSite/AtomFeed.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 22.06.21. 6 | // 7 | 8 | import Foundation 9 | import Swim 10 | 11 | public struct FeedItem { 12 | public init(link: String, title: String, description: String, date: Date) { 13 | self.link = link 14 | self.title = title 15 | self.description = description 16 | self.date = date 17 | } 18 | 19 | var link: String // relative link 20 | var title: String 21 | var description: String 22 | var date: Date 23 | } 24 | 25 | public struct AtomFeed: NodeConvertible { 26 | public init(destination: String, title: String, absoluteURL: String, description: String, items: [FeedItem]) { 27 | self.destination = destination 28 | self.title = title 29 | self.absoluteURL = absoluteURL 30 | self.description = description 31 | self.items = items 32 | } 33 | 34 | let dateFormatter: DateFormatter = { 35 | var df = DateFormatter() 36 | df.locale = Locale(identifier: "en_us") 37 | df.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" 38 | return df 39 | }() 40 | 41 | let destination: String 42 | let title: String 43 | let absoluteURL: String 44 | let feedPath: String = "/feed.xml" 45 | let description: String 46 | 47 | let items: [FeedItem] 48 | 49 | public func asNode() -> Node { 50 | rss(version: "2.0", xmlns: "http://www.w3.org/2005/Atom") { 51 | channel { 52 | title { self.title } 53 | description { description } 54 | link { absoluteURL } 55 | atomLink(href: "\(absoluteURL)\(feedPath)", rel: "self") 56 | items.map { post -> Node in 57 | let permalink = "\(absoluteURL)\(post.link)" 58 | return item { 59 | title { post.title } 60 | description { 61 | post.description.replaceAbsoluteURLs(prefix: absoluteURL + "/") 62 | } 63 | pubDate { 64 | post.date 65 | } 66 | link { permalink } 67 | guid { permalink } 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | // This code is a modified version of https://github.com/robb/robb.swift/blob/main/Sources/robb.swift/Pages/AtomFeed.swift 76 | extension AtomFeed { 77 | private func rss(version: String, xmlns: String, @NodeBuilder children: () -> NodeConvertible) -> Node { 78 | .element("rss", [ "version": version, "xmlns:atom": xmlns ], children().asNode()) 79 | } 80 | 81 | private func description(@NodeBuilder children: () -> NodeConvertible) -> Node { 82 | .element("description", [:], children().asNode()) 83 | } 84 | 85 | private func channel(@NodeBuilder children: () -> NodeConvertible) -> Node { 86 | .element("channel", [:], children().asNode()) 87 | } 88 | 89 | private func title(children: () -> String) -> Node { 90 | .element("title", [:], %children().asNode()%) 91 | } 92 | 93 | private func guid(isPermaLink: Bool = true, children: () -> String) -> Node { 94 | .element("guid", ["isPermaLink": isPermaLink ? "true" : "false"], %children().asNode()%) 95 | } 96 | 97 | private func pubDate(date: () -> Date) -> Node { 98 | .element("pubDate", [:], %.text(dateFormatter.string(from: date()))%) 99 | } 100 | 101 | private func item(@NodeBuilder children: () -> NodeConvertible) -> Node { 102 | .element("item", [:], children().asNode()) 103 | } 104 | 105 | private func atomLink(href: String, rel: String, type: String = "application/rss+xml") -> Node { 106 | .element("atom:link", [ "href": href, "rel": rel, "type": type], nil) 107 | } 108 | 109 | private func link(children: () -> String) -> Node { 110 | .element("link", [:], %children().asNode()%) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/StaticSite/CopyRule.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 14.06.21. 6 | // 7 | 8 | import Foundation 9 | 10 | 11 | public struct Copy: Rule, BuiltinRule { 12 | public init(_ name: String) { 13 | self.init(from: name, to: name) 14 | } 15 | 16 | public init(from: String, to: String) { 17 | self.from = from 18 | self.to = to 19 | } 20 | 21 | public init(contentsOf: String, to: String) { 22 | self.from = contentsOf 23 | self.to = to 24 | copyContents = true 25 | } 26 | 27 | var copyContents = false 28 | var from: String 29 | var to: String 30 | 31 | public func run(environment: EnvironmentValues) throws { 32 | let fm = FileManager.default 33 | let source = environment.inputBaseURL.appendingPathComponent(from) 34 | let destination = environment.output.appendingPathComponent(to) 35 | let destinationDir = destination.deletingLastPathComponent() 36 | if !fm.fileExists(atPath: destinationDir.path, isDirectory: nil) { 37 | try fm.createDirectory(at: destinationDir, withIntermediateDirectories: true, attributes: nil) 38 | } 39 | if copyContents { 40 | let paths = try fm.contentsOfDirectory(atPath: source.path) 41 | for p in paths { 42 | try fm.copyItem(at: source.appendingPathComponent(p), to: destination.appendingPathComponent(p)) 43 | } 44 | } else { 45 | try fm.copyItem(at: source, to: destination) 46 | let base = environment.inputBaseURL 47 | let hashed = environment.hashedAssetNames 48 | let out = environment.output 49 | for file in fm.allFiles(at: source) { 50 | if let dest = hashed[file] { 51 | try fm.copyItem(at: base.appendingPathComponent(file), to: out.appendingPathComponent(dest)) 52 | } 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/StaticSite/EnvironmentValues.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 31.05.21. 6 | // 7 | 8 | import Foundation 9 | import Swim 10 | 11 | public protocol EnvironmentKey { 12 | associatedtype Value 13 | static var defaultValue: Value { get } 14 | } 15 | 16 | public struct EnvironmentValues { 17 | public init(fileManager: FileManager = FileManager.default, inputBaseURL: URL, outputBaseURL: URL) { 18 | self.fileManager = fileManager 19 | self.inputBaseURL = inputBaseURL 20 | self.outputBaseURL = outputBaseURL 21 | self.transformNode = { $1 } 22 | } 23 | 24 | public var fileManager = FileManager.default 25 | public var inputBaseURL: URL 26 | public internal(set) var relativeOutputPath = "/" 27 | public var templates: [Template] = [] 28 | public var outputBaseURL: URL 29 | public var output: URL { 30 | outputBaseURL.appendingPathComponent(relativeOutputPath) 31 | } 32 | public var transformNode: (EnvironmentValues, Node) -> Node // this runs before rendering a node 33 | var userDefined: [ObjectIdentifier:Any] = [:] 34 | 35 | public subscript(key: Key.Type = Key.self) -> Key.Value { 36 | get { 37 | userDefined[ObjectIdentifier(key)] as? Key.Value ?? Key.defaultValue 38 | } 39 | set { 40 | userDefined[ObjectIdentifier(key)] = newValue 41 | } 42 | } 43 | } 44 | 45 | enum HashedAssetNames: EnvironmentKey { 46 | static var defaultValue: [String:String] = [:] 47 | } 48 | 49 | extension EnvironmentValues { 50 | public var hashedAssetNames: [String:String] { 51 | get { 52 | self[HashedAssetNames.self] 53 | } 54 | set { 55 | self[HashedAssetNames.self] = newValue 56 | } 57 | } 58 | } 59 | 60 | extension Rule { 61 | public func hashedAssetNames(_ names: [String:String]) -> some Rule { 62 | modifyEnvironment(keyPath: \.hashedAssetNames, modify: { $0.merge(names, uniquingKeysWith: { fatalError("Duplicate asset name \($1)" )}) }) 63 | } 64 | } 65 | 66 | extension EnvironmentValues { 67 | public var currentPath: URL { 68 | inputBaseURL 69 | } 70 | 71 | public func allFiles(at relativePath: String) throws -> [String] { 72 | try fileManager.contentsOfDirectory(atPath: inputBaseURL.appendingPathComponent(relativePath).path) 73 | } 74 | 75 | public func read(_ relativePath: String) throws -> String { 76 | return try String(contentsOf: currentPath.appendingPathComponent(relativePath)) 77 | } 78 | 79 | public func read(_ relativePath: String) throws -> Data { 80 | return try Data(contentsOf: currentPath.appendingPathComponent(relativePath)) 81 | } 82 | } 83 | 84 | struct EnvironmentModifier: Builtin { 85 | init(content: Content, keyPath: WritableKeyPath, modify: @escaping (inout A) -> ()) { 86 | self.content = content 87 | self.keyPath = keyPath 88 | self.modify = modify 89 | } 90 | 91 | var content: Content 92 | var keyPath: WritableKeyPath 93 | var modify: (inout A) -> () 94 | 95 | func run(environment: EnvironmentValues) throws { 96 | var copy = environment 97 | modify(©[keyPath: keyPath]) 98 | try content.builtin.run(environment: copy) 99 | } 100 | } 101 | 102 | public extension Rule { 103 | func environment(keyPath: WritableKeyPath, value: A) -> some Rule { 104 | EnvironmentModifier(content: self, keyPath: keyPath, modify: { $0 = value }) 105 | } 106 | 107 | func modifyEnvironment(keyPath: WritableKeyPath, modify: @escaping (inout A) -> ()) -> some Rule { 108 | EnvironmentModifier(content: self, keyPath: keyPath, modify: modify ) 109 | } 110 | } 111 | 112 | // Convenience 113 | 114 | extension Rule { 115 | public func outputPath(_ string: String) -> some Rule { 116 | modifyEnvironment(keyPath: \.relativeOutputPath, modify: { path in 117 | path = (path as NSString).appendingPathComponent(string) 118 | }) 119 | } 120 | } 121 | 122 | extension EnvironmentValues { 123 | public func write(_ data: Data) throws { 124 | let name = output 125 | let directory = name.deletingLastPathComponent() 126 | var isDirectory: ObjCBool = false 127 | let dirExists = fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) 128 | if !dirExists || !isDirectory.boolValue { 129 | try? fileManager.removeItem(at: directory) 130 | try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) 131 | } 132 | try data.write(to: name) 133 | } 134 | } 135 | 136 | @available(*, deprecated, message: "Use @Enviroment instead") 137 | public struct EnvironmentReader: Builtin { 138 | var content: (EnvironmentValues) -> R 139 | 140 | public init(@RuleBuilder _ r: @escaping (EnvironmentValues) -> R) { 141 | self.content = r 142 | } 143 | public func run(environment: EnvironmentValues) throws { 144 | try content(environment) 145 | .builtin 146 | .run(environment: environment) 147 | } 148 | } 149 | 150 | extension EnvironmentValues { 151 | func install(on: A) { 152 | let m = Mirror(reflecting: on) 153 | for child in m.children { 154 | if let e = child.value as? SetEnvironment { 155 | e.set(environment: self) 156 | } 157 | } 158 | } 159 | } 160 | 161 | @propertyWrapper 162 | class Box { 163 | var wrappedValue: A 164 | init(wrappedValue: A) { 165 | self.wrappedValue = wrappedValue 166 | } 167 | } 168 | 169 | protocol SetEnvironment { 170 | func set(environment: EnvironmentValues) 171 | } 172 | 173 | @propertyWrapper 174 | public struct Environment: SetEnvironment { 175 | var keyPath: KeyPath 176 | @Box fileprivate var values: EnvironmentValues? 177 | 178 | public init(_ keyPath: KeyPath) { 179 | self.keyPath = keyPath 180 | } 181 | 182 | public var wrappedValue: Value? { 183 | values![keyPath: keyPath] 184 | } 185 | 186 | func set(environment: EnvironmentValues) { 187 | values = environment 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /Sources/StaticSite/ForEach.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 01.06.21. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct ForEach: Builtin { 11 | public init(_ data: [Element], @RuleBuilder content: @escaping (Element) -> Content) { 12 | self.data = data 13 | self.content = content 14 | } 15 | 16 | var data: [Element] 17 | var content: (Element) -> Content 18 | var parallel: Bool = false // this can cause problems with the environment! 19 | 20 | public func run(environment: EnvironmentValues) throws { 21 | if parallel { 22 | let group = DispatchGroup() 23 | let q = DispatchQueue.global() 24 | for element in data { 25 | group.enter() 26 | q.async { 27 | try! content(element).builtin.run(environment: environment) 28 | group.leave() 29 | } 30 | } 31 | group.wait() 32 | } else { 33 | for element in data { 34 | try content(element).builtin.run(environment: environment) 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/StaticSite/MarkdownToSwim.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 21.06.21. 6 | // 7 | 8 | import Foundation 9 | import HTML 10 | import Markdown 11 | import Swim 12 | 13 | extension Markup { 14 | public func toNode() -> Node { 15 | var b = HTMLBuilder() 16 | return b.visit(self) 17 | } 18 | } 19 | 20 | extension String { 21 | public func markdown() -> Node { 22 | Document(parsing: self).toNode() 23 | } 24 | } 25 | 26 | // todo should this be a visitor? 27 | struct HTMLBuilder: MarkupVisitor { 28 | typealias Result = Node 29 | 30 | mutating func visit(_ children: MarkupChildren) -> Node { 31 | .fragment(children.map { visit($0) }) 32 | } 33 | 34 | mutating func defaultVisit(_ markup: Markup) -> Node { 35 | visit(markup.children) 36 | } 37 | 38 | 39 | func visitText(_ text: Markdown.Text) -> Node { 40 | text.string.asNode() 41 | } 42 | 43 | func visitLineBreak(_ lineBreak: LineBreak) -> Node{ 44 | br() 45 | } 46 | 47 | 48 | func visitInlineHTML(_ inlineHTML: InlineHTML) -> Node { 49 | .raw(inlineHTML.rawHTML) 50 | } 51 | 52 | mutating func visitEmphasis(_ emphasis: Emphasis) -> Node { 53 | %HTML.em { visit(emphasis.children) } 54 | } 55 | 56 | mutating func visitStrong(_ strong: Strong) -> Node { 57 | %HTML.strong { visit(strong.children) } 58 | } 59 | func visitCustomInline(_ customInline: CustomInline) -> Node { 60 | customInline.text.asNode() 61 | } 62 | 63 | mutating func visitLink(_ link: Link) -> Node { 64 | %a(href: link.destination) { 65 | visit(link.children) 66 | }% 67 | } 68 | 69 | func visitImage(_ image: Image) -> Node { 70 | %img(src: image.source, title: image.title)% 71 | } 72 | 73 | mutating func visitOrderedList(_ orderedList: OrderedList) -> Node { 74 | ol { visit(orderedList.children) } 75 | } 76 | 77 | mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Node { 78 | ul { visit(unorderedList.children) } 79 | } 80 | 81 | mutating func visitListItem(_ listItem: ListItem) -> Node { 82 | li { visit(listItem.children) } 83 | } 84 | 85 | mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Node { 86 | blockquote { visit(blockQuote.children) } 87 | } 88 | 89 | mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Node { 90 | let cl = codeBlock.language 91 | return pre { 92 | %HTML.code(class: cl) { 93 | codeBlock.code 94 | }% 95 | } 96 | } 97 | 98 | func visitInlineCode(_ inlineCode: InlineCode) -> Node { 99 | %HTML.code { inlineCode.code }% 100 | } 101 | 102 | func visitHTMLBlock(_ html: HTMLBlock) -> Node { 103 | .raw(html.rawHTML) 104 | } 105 | 106 | mutating func visitHeading(_ heading: Heading) -> Node { 107 | let text = visit(heading.children) 108 | switch heading.level { 109 | case 1: return h1 { text } 110 | case 2: return h2 { text } 111 | case 3: return h3 { text } 112 | case 4: return h4 { text } 113 | case 5: return h5 { text } 114 | default: return h6 { text } 115 | } 116 | } 117 | 118 | mutating func visitParagraph(_ paragraph: Paragraph) -> Node { 119 | p { visit(paragraph.children) } 120 | } 121 | 122 | func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Node { 123 | hr() 124 | } 125 | 126 | mutating func visitCustomBlock(_ customBlock: CustomBlock) -> Node { 127 | visit(customBlock.children) 128 | } 129 | } 130 | 131 | -------------------------------------------------------------------------------- /Sources/StaticSite/Measure.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 15.06.21. 6 | // 7 | 8 | import Foundation 9 | 10 | fileprivate struct Measure: BuiltinRule, Rule { 11 | var rule: R 12 | 13 | func run(environment: EnvironmentValues) throws { 14 | let start = Date() 15 | try rule.builtin.run(environment: environment) 16 | let end = Date() 17 | print("\(R.self): \(end.timeIntervalSince(start))") 18 | } 19 | } 20 | 21 | extension Rule { 22 | public func measure() -> some Rule { 23 | Measure(rule: self) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/StaticSite/ParseHelpers.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 11.04.20. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Character { 11 | public var isDecimalDigit: Bool { 12 | return self.isHexDigit && self.hexDigitValue! < 10 // todo? 13 | } 14 | 15 | public var isIdentifier: Bool { 16 | return isLetter || isNumber || self == "_" || self == "-" 17 | } 18 | 19 | public var isIdentifierStart: Bool { 20 | return isLetter || isNumber || self == "_" 21 | } 22 | } 23 | 24 | extension Substring { 25 | @discardableResult mutating public func remove(prefix: String) -> Bool { 26 | guard hasPrefix(prefix) else { return false } 27 | removeFirst(prefix.count) 28 | return true 29 | } 30 | 31 | @discardableResult 32 | mutating public func remove(while cond: (Element) -> Bool) -> Self? { 33 | guard let end = firstIndex(where: { !cond($0) }) else { 34 | let remainder = self 35 | self.removeAll() 36 | return remainder 37 | } 38 | let result = self[.. Self? { 45 | guard let newLine = firstIndex(where: { $0.isNewline }) else { 46 | let result = self 47 | self.removeAll() 48 | return result 49 | } 50 | let end = self.index(after: newLine) 51 | let result = self[.. () 11 | 12 | public init(_ value: R) { 13 | self._run = { env in 14 | env.install(on: value) 15 | try value.body.builtin.run(environment: env) 16 | } 17 | } 18 | 19 | public init(any value: any Rule) { 20 | if let b = value as? any Builtin { 21 | self._run = { try b.run(environment: $0) } 22 | } else { 23 | self._run = { env in 24 | env.install(on: value) 25 | try value.body.builtin.run(environment: env) 26 | } 27 | } 28 | } 29 | 30 | public func run(environment: EnvironmentValues) throws { 31 | try _run(environment) 32 | } 33 | } 34 | 35 | public extension BuiltinRule { 36 | typealias Body = Never 37 | var body: Never { 38 | fatalError("This should never happen") 39 | } 40 | } 41 | 42 | extension Rule where Body == Never { 43 | func run() { fatalError() } 44 | } 45 | 46 | extension Never: Rule { 47 | public typealias Body = Never 48 | public var body: Never { fatalError() } 49 | } 50 | 51 | public protocol Rule { 52 | associatedtype Body: Rule 53 | @RuleBuilder var body: Body { get } 54 | } 55 | 56 | extension Rule { 57 | public var builtin: BuiltinRule { 58 | if let x = self as? BuiltinRule { return x } 59 | return AnyBuiltin(self) 60 | } 61 | } 62 | 63 | public struct EmptyRule: Builtin { 64 | public init() { } 65 | public func run(environment: EnvironmentValues) { } 66 | } 67 | 68 | extension Optional: Builtin where Wrapped: Rule { 69 | public func run(environment: EnvironmentValues) throws { 70 | try self?.builtin.run(environment: environment) 71 | } 72 | } 73 | 74 | public struct RuleGroup: Builtin { 75 | var content: Content 76 | 77 | public init(@RuleBuilder content: () -> Content) { 78 | self.content = content() 79 | } 80 | 81 | public func run(environment: EnvironmentValues) throws { 82 | try content.builtin.run(environment: environment) 83 | } 84 | } 85 | 86 | public struct Pair: Builtin where L: Rule, R: Rule { 87 | var value: (L, R) 88 | init(_ l: L, _ r: R) { 89 | self.value = (l,r) 90 | } 91 | 92 | public func run(environment: EnvironmentValues) throws { 93 | try value.0.builtin.run(environment: environment) 94 | try value.1.builtin.run(environment: environment) 95 | } 96 | } 97 | 98 | public enum Choice: Builtin where L: Rule, R: Rule { 99 | case left(L) 100 | case right(R) 101 | 102 | public func run(environment: EnvironmentValues) throws { 103 | switch self { 104 | case .left(let rule): 105 | try rule.builtin.run(environment: environment) 106 | case .right(let rule): 107 | try rule.builtin.run(environment: environment) 108 | } 109 | } 110 | } 111 | 112 | @resultBuilder 113 | public enum RuleBuilder { 114 | public static func buildBlock() -> EmptyRule { 115 | EmptyRule() 116 | } 117 | 118 | public static func buildIf(_ content: Content?) -> Content? where Content : Rule { 119 | content 120 | } 121 | 122 | public static func buildBlock(_ content: Content) -> Content where Content : Rule { 123 | content 124 | } 125 | 126 | public static func buildEither(first component: L) -> Choice { 127 | .left(component) 128 | } 129 | 130 | public static func buildEither(second component: R) -> Choice { 131 | .right(component) 132 | } 133 | 134 | 135 | public static func buildBlock(_ c0: C0, _ c1: C1) -> Pair where C0 : Rule, C1 : Rule { 136 | return Pair(c0, c1) 137 | } 138 | 139 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2) -> Pair> where C0 : Rule, C1 : Rule, C2: Rule { 140 | return Pair(c0, Pair(c1, c2)) 141 | } 142 | 143 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3) -> Pair>> where C0 : Rule, C1 : Rule, C2: Rule, C3: Rule { 144 | return Pair(c0, Pair(c1, Pair(c2, c3))) 145 | } 146 | 147 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> Pair>>> where C0 : Rule, C1 : Rule, C2: Rule, C3: Rule, C4: Rule { 148 | return Pair(c0, Pair(c1, Pair(c2, Pair(c3, c4)))) 149 | } 150 | 151 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> Pair>>>> where C0 : Rule, C1 : Rule, C2: Rule, C3: Rule, C4: Rule, C5: Rule { 152 | return Pair(c0, Pair(c1, Pair(c2, Pair(c3, Pair(c4, c5))))) 153 | } 154 | 155 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6) -> Pair>>>>> where C0 : Rule, C1 : Rule, C2: Rule, C3: Rule, C4: Rule, C5: Rule, C6: Rule { 156 | return Pair(c0, Pair(c1, Pair(c2, Pair(c3, Pair(c4, Pair(c5, c6)))))) 157 | } 158 | 159 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7) -> Pair>>>>>> where C0 : Rule, C1 : Rule, C2: Rule, C3: Rule, C4: Rule, C5: Rule, C6: Rule, C7: Rule { 160 | return Pair(c0, Pair(c1, Pair(c2, Pair(c3, Pair(c4, Pair(c5, Pair(c6, c7))))))) 161 | } 162 | 163 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8) -> Pair>>>>>>> where C0 : Rule, C1 : Rule, C2: Rule, C3: Rule, C4: Rule, C5: Rule, C6: Rule, C7: Rule, C8: Rule { 164 | return Pair(c0, Pair(c1, Pair(c2, Pair(c3, Pair(c4, Pair(c5, Pair(c6, Pair(c7, c8)))))))) 165 | } 166 | 167 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9) -> Pair>>>>>>>> where C0 : Rule, C1 : Rule, C2: Rule, C3: Rule, C4: Rule, C5: Rule, C6: Rule, C7: Rule, C8: Rule, C9: Rule { 168 | return Pair(c0, Pair(c1, Pair(c2, Pair(c3, Pair(c4, Pair(c5, Pair(c6, Pair(c7, Pair(c8, c9))))))))) 169 | } 170 | 171 | public static func buildBlock(_ c0: C0, _ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5, _ c6: C6, _ c7: C7, _ c8: C8, _ c9: C9, _ c10: C10) -> Pair>>>>>>>>> where C0 : Rule, C1 : Rule, C2: Rule, C3: Rule, C4: Rule, C5: Rule, C6: Rule, C7: Rule, C8: Rule, C9: Rule, C10: Rule { 172 | return Pair(c0, Pair(c1, Pair(c2, Pair(c3, Pair(c4, Pair(c5, Pair(c6, Pair(c7, Pair(c8, Pair(c9, c10)))))))))) 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /Sources/StaticSite/Template.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 28.06.21. 6 | // 7 | 8 | import Foundation 9 | import Swim 10 | 11 | enum TemplateKey: EnvironmentKey { 12 | static var defaultValue: [Template] = [] 13 | } 14 | 15 | extension EnvironmentValues { 16 | public var template: [Template] { 17 | get { self[TemplateKey.self] } 18 | set { self[TemplateKey.self] = newValue } 19 | } 20 | } 21 | 22 | extension Rule { 23 | public func wrap(_ template: Template) -> some Rule { 24 | self.modifyEnvironment(keyPath: \.template, modify: { $0.append(template) }) 25 | } 26 | 27 | public func resetTemplates() -> some Rule { 28 | self.modifyEnvironment(keyPath: \.template, modify: { $0 = [] }) 29 | } 30 | } 31 | 32 | public protocol Template { 33 | func run(content: Node) -> Node 34 | } 35 | -------------------------------------------------------------------------------- /Sources/StaticSite/URLReplacer.swift: -------------------------------------------------------------------------------- 1 | extension Character { 2 | var isQuote: Bool { 3 | self == "\"" || self == "'" 4 | } 5 | } 6 | 7 | extension String { 8 | public func replaceAbsoluteURLs(prefix: String) -> String { 9 | let attributes = [ 10 | "src=", 11 | "href=", 12 | "url=", 13 | "action=", 14 | "srcset=" 15 | ] // https://github.com/gohugoio/hugo/blob/master/transform/urlreplacers/absurlreplacer.go 16 | 17 | let startingLetter = Set(attributes.map { $0.first! }) 18 | 19 | var remainder = self[...] 20 | var result = "" 21 | while let f = remainder.first { 22 | if startingLetter.contains(f) { 23 | for a in attributes { 24 | if remainder.remove(prefix: a) { 25 | result.append(a) 26 | guard remainder.first?.isQuote == true else { continue } 27 | let q = remainder.removeFirst() 28 | result.append(q) 29 | if a == "srcset=" { 30 | guard let endIdx = remainder.firstIndex(of: q) else { continue } 31 | let srcset = remainder[.. String { 48 | // todo rather than traversing the tree twice we could merge the traversals 49 | 50 | var output = "" 51 | if xml { 52 | output.append("\n") 53 | } 54 | self.write(to: &output) 55 | return output 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/StaticSite/YamlWithFrontMatter.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Chris Eidhof on 01.06.21. 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | // Parses a yaml front matter delimeted by --- 12 | public func parseMarkdownWithFrontMatter() throws -> (yaml: String?, markdown: String) { 13 | var remainder = self[...] 14 | remainder.remove(while: { $0.isWhitespace }) 15 | if remainder.remove(prefix: "---") { 16 | let start = remainder.startIndex 17 | var end = remainder.startIndex 18 | while !remainder.isEmpty, !remainder.remove(prefix: "---") { 19 | remainder.removeLine() 20 | end = remainder.startIndex 21 | } 22 | let yaml = String(self[start..