├── .gitattributes ├── .gitignore ├── Example ├── .gitignore ├── Content │ ├── post1.md │ └── post2.md ├── Package.resolved ├── Package.swift └── Sources │ └── main.swift ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources ├── Genesis │ ├── Batteries │ │ ├── ContentBundle.swift │ │ └── EstimatedReadingTime.swift │ ├── Core │ │ ├── Content.swift │ │ ├── Context.swift │ │ ├── Page.swift │ │ └── Site.swift │ ├── Extensions │ │ ├── Date-RFC822.swift │ │ ├── String-AbsoluteLinks.swift │ │ ├── String-Slug.swift │ │ ├── String-StrippingTags.swift │ │ ├── URL+Date.swift │ │ ├── URL-Relative.swift │ │ └── URL-SelectDirectories.swift │ └── SecondaryCore │ │ ├── Language.swift │ │ ├── Location.swift │ │ └── PublishingError.swift └── GenesisMarkdown │ └── MarkdownToHTML.swift └── Tests └── GenesisTests └── GenesisTests.swift /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | Example/output 10 | -------------------------------------------------------------------------------- /Example/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Example/Content/post1.md: -------------------------------------------------------------------------------- 1 | # Post 1 2 | 3 | This is the first post of your blog. 4 | -------------------------------------------------------------------------------- /Example/Content/post2.md: -------------------------------------------------------------------------------- 1 | # Post 2 2 | 3 | And this is another post! 4 | -------------------------------------------------------------------------------- /Example/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "89e93c2fb689ec147210708947229868d8e8ef1423fb02b4abc7191702453b1a", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-cmark", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swiftlang/swift-cmark.git", 8 | "state" : { 9 | "branch" : "gfm", 10 | "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-markdown", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-markdown.git", 17 | "state" : { 18 | "branch" : "main", 19 | "revision" : "90a8e2ec3d814093435b76c5e87917bd81785fd4" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Example/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Example", 6 | platforms: [.macOS(.v14)], 7 | dependencies: [ 8 | .package(path: ".."), 9 | // .package(url: "https://github.com/alexito4/Genesis.git", branch: "main") 10 | ], 11 | targets: [ 12 | .executableTarget( 13 | name: "Example", 14 | dependencies: [ 15 | .product(name: "Genesis", package: "Genesis"), 16 | .product(name: "GenesisMarkdown", package: "Genesis"), 17 | ] 18 | ), 19 | ] 20 | ) 21 | -------------------------------------------------------------------------------- /Example/Sources/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Genesis 3 | import GenesisMarkdown 4 | 5 | // 1. Define a `Site` 6 | struct ExampleSite: Site { 7 | var name = "Example" 8 | var description: String? = "An example site built with Genesis" 9 | var author = "Alejandro M. P." 10 | var url = URL(string: "127.0.0.1")! 11 | var language: Language = .english 12 | } 13 | 14 | // 2. Instantiate the site and take the context from it 15 | let site = ExampleSite() 16 | let context = try site.context() 17 | 18 | // 3. And just call the functions you want. The Genesis Context is just here to help you, but you are in full control of how your site generation works. 19 | 20 | // You might want to clear the build folder on every generation... 21 | // or not? Maybe you want to skip the cleaning while drafting. 22 | try await context.clearBuildFolder() 23 | 24 | // You might want to copy all the assets from the default Asset folder into the final output 25 | // Or you might have a more sophisticated asset pipeline that you prefer instead of this 26 | // try await context.copyAssets() 27 | 28 | // If your site has content defined by files like markdown blog posts, you can load that into the context. 29 | // For that define a `ContentLoader` conforming type 30 | struct BlogLoader: ContentLoader { 31 | // Implement a load function that returns the content 32 | func load(context: Context) async throws -> sending [any Content] { 33 | // Just load the files from the directory you keep them. 34 | // This let's you structure your site however you want. 35 | // In this example we just have posts in the Content folder directly 36 | let contentDirectory = await context.contentDirectory 37 | let contents: [URL] = try FileManager.default.contentsOfDirectory( 38 | at: contentDirectory, 39 | includingPropertiesForKeys: [.creationDateKey] 40 | ) 41 | return try contents 42 | // Perform any logic you want with your content. You may want to exclude some files, or posts based on some rules 43 | .filter { $0.lastPathComponent != ".DS_Store" } 44 | // Map to a specific type conforming to `Content` 45 | .map { fileURL in 46 | // GenesisMarkdown gives you the capability to load and parse markdown files 47 | // But it's just a thin wrapper on top of swift-markdown so feel free to write your own! 48 | // You can even use any other markdown library if you prefer. 49 | let markdown = try ParsedMarkdown(parsing: fileURL) 50 | 51 | // The only requirement for any Content is to specify the path in the website 52 | // Here we take it from the markdown file, but you can use your own strategy, like using 53 | // file dates, using the markdown front matter, etc. 54 | let path = fileURL 55 | .relative(to: contentDirectory) 56 | 57 | // Genesis comes with some extra batteries, like a way to estimate the reading time. 58 | let readingTime = EstimatedReadingTime(for: markdown.body) 59 | 60 | return BlogPost( 61 | // The only mandatory part of the Content is the path 62 | path: path, 63 | readingTime: readingTime, 64 | // You control your own content type and loading, so for example you can keep the markdown AST in the content for later modification or analysis 65 | markdown: markdown, 66 | // or just store the parsed HTML string 67 | htmlBody: markdown.body 68 | ) 69 | } 70 | } 71 | } 72 | 73 | struct BlogPost: Content { 74 | var path: String 75 | var readingTime: EstimatedReadingTime 76 | var markdown: ParsedMarkdown 77 | var htmlBody: String 78 | } 79 | 80 | // Load content on the context so it can be retrieved by later steps 81 | try await context.loadContent(from: [ 82 | BlogLoader(), 83 | ]) 84 | 85 | // Generate static single pages 86 | // Implement any type that conforms to `Page` 87 | struct HomePage: Page { 88 | // A Page, just like the Content, requires a path to know where it goes in the final output 89 | var path: String = "" 90 | 91 | // A page just has a render method that gives you the context and expects a String to save into a file. 92 | // That's it! You can implement this however you want. 93 | func render(context: Context) async throws -> String { 94 | // you could load pre-made HTML templates from the file system... 95 | // or use a Swift HTML DSL library... 96 | // or just use Swift string interpolation 97 | await """ 98 | 99 | 100 |

Home page of your site

101 | 104 | 105 | 106 | """ 107 | } 108 | 109 | // Is all just normal Swift code, with very little enforced by Genesis. 110 | private func postsListItems(context: Context) async -> String { 111 | // You have access to the loaded content in the context, so you can list it in any page you want 112 | await context.content(of: BlogPost.self) 113 | .map { 114 | """ 115 |
  • \($0.markdown.title)
  • 116 | """ 117 | } 118 | .joined() 119 | } 120 | } 121 | 122 | // Use Genesis to generate the pages 123 | try await context.generateStaticPages(pages: [ 124 | HomePage(), 125 | ]) 126 | 127 | // Other pages are not a single static page, but a templated page instantiated from a set of data 128 | // We can create providers to create as many pages as needed dynamically 129 | // For example we can make a `PageProvider` that create a `Page` for each loaded `BlogPost` 130 | struct BlogPostProvider: PageProvider { 131 | func source(context: Genesis.Context) async throws -> [any Page] { 132 | // The context has a few helpers to find the content you want 133 | let blogPosts = await context.content(of: BlogPost.self) 134 | return blogPosts.map { BlogPostPage(path: $0.path, post: $0) } 135 | } 136 | } 137 | 138 | // Giving providers to the context so it calls each of them, and generates all the pages provided 139 | try await context.generateContentPages(providers: [ 140 | BlogPostProvider(), 141 | ]) 142 | // A blog post page is just like any other page, but since a provider creates an instance for each content 143 | // you can have properties that are specific for each instance of the content. 144 | struct BlogPostPage: Page { 145 | var path: String 146 | 147 | // In this case we keep the blog post around so we can use it for rendering the page 148 | var post: BlogPost 149 | 150 | func render(context: Genesis.Context) async throws -> String { 151 | """ 152 | 153 | 154 |

    \(post.markdown.title)

    155 | \(post.htmlBody) 156 | 157 | 158 | """ 159 | } 160 | } 161 | 162 | // In the end you can generate some standard extra pages. 163 | // As you can see you can call multiple times the functions on the context, they are just here to help, not to enforce a structure 164 | try await context.generateStaticPages(pages: [ 165 | // NotFoundPage(), 166 | // SiteMap(), 167 | // Robots(site: site), 168 | ]) 169 | 170 | print("Site generated.") 171 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alejandro Martínez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "af463355025e7288cfc790e04d36c8dba353809746b25edbcf2e681eca80d315", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-cmark", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swiftlang/swift-cmark.git", 8 | "state" : { 9 | "branch" : "gfm", 10 | "revision" : "b022b08312decdc46585e0b3440d97f6f22ef703" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-markdown", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-markdown.git", 17 | "state" : { 18 | "branch" : "main", 19 | "revision" : "2829eefc2e4f8ee7270b8e8d3e5757b98fe06b6c" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Genesis", 6 | platforms: [.macOS(.v14)], 7 | products: [ 8 | .library( 9 | name: "Genesis", 10 | targets: ["Genesis"] 11 | ), 12 | .library( 13 | name: "GenesisMarkdown", 14 | targets: ["GenesisMarkdown"] 15 | ), 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/apple/swift-markdown.git", branch: "main"), 19 | ], 20 | targets: [ 21 | .target( 22 | name: "Genesis", 23 | dependencies: [] 24 | ), 25 | .testTarget( 26 | name: "GenesisTests", 27 | dependencies: ["Genesis"] 28 | ), 29 | .target( 30 | name: "GenesisMarkdown", 31 | dependencies: [ 32 | .product(name: "Markdown", package: "swift-markdown"), 33 | ] 34 | ), 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Genesis 2 | 3 | A static site generator written in Swift. The engine behind [Alejandro M. P.](https://alejandromp.com). 4 | 5 | Read more about it in [Back to the basics with Genesis](https://alejandromp.com/development/blog/back-to-the-basics-with-genesis). 6 | 7 | ## By me, for me 8 | 9 | This generator is tailored to my needs. Although is flexible and capable to solve your problems, it's not designed for being simple to quick start but for being simple to maintain. If you are looking for something that holds your hands more I recommend other projects from the community like [Ignite](https://github.com/twostraws/Ignite), [Publish](https://github.com/JohnSundell/Publish), [Toucan](https://github.com/toucansites/toucan) or [others](https://github.com/topics/static-site-generator?l=swift). 10 | 11 | ## Features and non-features 12 | 13 | - The Core framework gives you APIs to load content and generate pages. 14 | - Simple function calls. No fancy declarative rules or steps systems. 15 | - Procedural. Not declared as part of the site, you call the functions however you want. 16 | - No hardcoded content or behaviour. You won't suffer if you want to customize anything after the first five minutes of excitement. 17 | - You add the RSS, Sitemap, Robots... 18 | - You define `ContentLoader`s to load dynamic `Content` 19 | - You define what the `Content` is and can have as many types as you want 20 | - You can load the content however you want (from file, a DB, a CMS...) 21 | - Use any Markdown parser 22 | - `GenesisMarkdown` is a separate module that uses [apple/swift-markdown](apple/swift-markdown) 23 | - This allows you to keep the Markdown tree as part of your content until the last moment you want to generate the HTML, so various parts of the generation can inspect and even manipulate the tree. 24 | - And if you don't want this loader functionality, you can just create the pages directly. 25 | - You can just give static single Page instances to the generator 26 | - Ideal for single pages like Home, About... 27 | - `Page` has access to the `Context`, so you in exactly the same way you can make Index pages trivially 28 | - You can use `PageProvider` to instantiate dynamically other pages 29 | - This lets you instantiate every instance from loaded content. Like blog posts. 30 | - Pages are very flexible 31 | - Just require a path to know where the page goes in the final site. Nothing else. 32 | - In the page you must return the `String`, usually HTML, to be saved to disk 33 | - Everything else depends on what you want. They are just Swift types so you can keep any data you want, use async, or pass the data you need in the inits, useful with the `PageProvider` 34 | - No hardcoded theme 35 | - No hardcoded web dependencies (css frameworks, js, etc) 36 | - HTML generation is just to output strings 37 | - No fancy DSL for typed Swift HTML hardcoded into the library 38 | - This gives full flexibility on how you want to generate HTML 39 | - Go basic and return inlined Swift interpolated HTML strings 40 | - Or use templates from HTML files 41 | - Or use any typed Swift HTML library 42 | 43 | ## Usage 44 | 45 | `Genesis` is a Swift package and as such you just need to depend on it. 46 | 47 | ```swift 48 | .package(url: "https://github.com/alexito4/Genesis.git", branch: "main") 49 | ``` 50 | 51 | And include the module you need on your target: 52 | 53 | ```swift 54 | // The engine 55 | .product(name: "Genesis", package: "Genesis") 56 | 57 | // Markdown support 58 | .product(name: "GenesisMarkdown", package: "Genesis"), 59 | ``` 60 | 61 | 62 | ## Example 63 | 64 | Check the [Example](https://github.com/alexito4/Genesis/tree/main/Example) to see how the API can be used. 65 | 66 | -------------------------------------------------------------------------------- /Sources/Genesis/Batteries/ContentBundle.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Conformance for Content Bundles, ` 4 | /// a.k.a content items that are not just a markdown file, but that also include resources in the same folder. 5 | public protocol ContentWithResources: Content { 6 | var resourcesFolder: URL { get } 7 | } 8 | 9 | public extension Context { 10 | /// Copy the assets from each `ContentWithResources` to the output directory 11 | func copyContentResources() throws { 12 | let allContent = content(of: (any ContentWithResources).self) 13 | 14 | for content in allContent { 15 | let contentFolder = content.resourcesFolder 16 | 17 | let resources = try FileManager.default.contentsOfDirectory(at: contentFolder, includingPropertiesForKeys: nil) 18 | .filter { ![".DS_Store", "index.md"].contains($0.lastPathComponent) } 19 | 20 | let outputDirectory = buildDirectory.appending(path: content.path) 21 | 22 | for resource in resources { 23 | let destination = outputDirectory.appending(path: resource.lastPathComponent) 24 | try FileManager.default.copyItem(at: resource, to: destination) 25 | } 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Genesis/Batteries/EstimatedReadingTime.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public struct EstimatedReadingTime: Equatable, Sendable { 4 | /// Estimated words contained in the provided `String` given the used strategy 5 | public let words: Int 6 | 7 | /// A rough estimate of how many minutes it takes to read. 8 | public let timeMinutes: Double 9 | 10 | /// Rounded estimation of how many minutes it takes to read. 11 | public let minutes: Int 12 | 13 | init(words: Int, timeMinutes: Double, minutes: Int) { 14 | self.words = words 15 | self.timeMinutes = timeMinutes 16 | self.minutes = minutes 17 | } 18 | 19 | public init( 20 | for string: String, 21 | strategy: WordsEstimationStrategy = .regexWords, 22 | wordsPerMinute: Int = 250 23 | ) { 24 | let words = strategy.countWords(string) 25 | let minutes = Double(words) / Double(wordsPerMinute) 26 | self.init( 27 | words: words, 28 | timeMinutes: minutes, 29 | minutes: Int(minutes.rounded()) 30 | ) 31 | } 32 | 33 | public struct WordsEstimationStrategy: Sendable { 34 | let countWords: @Sendable (String) -> Int 35 | } 36 | } 37 | 38 | public extension EstimatedReadingTime.WordsEstimationStrategy { 39 | /// The original strategy used in https://github.com/alexito4/ReadingTimePublishPlugin 40 | /// Tried to strip out html tags to find only the words. 41 | /// Good for when the input is HTML. 42 | static let html: Self = .init { string in 43 | let plain = string.replacingOccurrences(of: "<[^>]+>", with: " ", options: .regularExpression, range: nil) 44 | let separators = CharacterSet 45 | .whitespacesAndNewlines 46 | .union(.punctuationCharacters) 47 | let words = plain.components(separatedBy: separators) 48 | .filter { !$0.isEmpty } 49 | return words.count 50 | } 51 | 52 | /// Used by https://github.com/twostraws/Ignite 53 | /// Uses regex engine to count the words. 54 | static let regexWords: Self = .init { string in 55 | string.matches(of: #/[\w-]+/#).count 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Genesis/Core/Content.swift: -------------------------------------------------------------------------------- 1 | 2 | /// A type that loads content to be added into the `Context` so later `Pages` can work with it. 3 | public protocol ContentLoader { 4 | func load(context: Context) async throws -> sending [any Content] 5 | } 6 | 7 | /// A single piece of content loaded. For example, a post. 8 | public protocol Content { 9 | /// This content path relative to the base URL. 10 | /// a.k.a. the path in the output folder. 11 | var path: String { get } 12 | } 13 | 14 | public extension Content { 15 | /// Get the full URL to this content. Useful for creating feed XML that includes 16 | /// this content. 17 | func path(in site: any Site) -> String { 18 | site.url.appending(path: path).absoluteString 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Genesis/Core/Context.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Site { 4 | /// Retrieve a `Context` for this `Site`. 5 | func context( 6 | from file: StaticString = #filePath, 7 | buildDirectoryPath: String = "output" 8 | ) throws -> Context { 9 | try Context( 10 | for: self, 11 | from: file, 12 | buildDirectoryPath: buildDirectoryPath 13 | ) 14 | } 15 | } 16 | 17 | public actor Context { 18 | /// The site that is currently being built. 19 | public nonisolated let site: any Site 20 | 21 | /// The root directory for the user's website package. 22 | public private(set) var rootDirectory: URL 23 | 24 | /// The directory containing their custom assets. 25 | public private(set) var assetsDirectory: URL 26 | 27 | /// The directory containing their Markdown files. 28 | public private(set) var contentDirectory: URL 29 | 30 | /// The directory containing includes to use with the `Include` element. 31 | // private(set) public var includesDirectory: URL 32 | 33 | /// The directory containing their final, built website. 34 | public private(set) var buildDirectory: URL 35 | 36 | /// Any warnings that have been issued during a build. 37 | private(set) var warnings = [String]() 38 | 39 | /// All the Markdown content this user has inside their Content folder. 40 | public private(set) var allContent = [any Content]() 41 | 42 | /// The sitemap for this site. Yes, using an array is less efficient when 43 | /// using `contains()`, but it allows us to list pages in a sensible order. 44 | /// (Technically speaking the order doesn't matter, but if the order changed 45 | /// randomly every time a build took place it would be annoying for source 46 | /// control!) 47 | public private(set) var siteMap = [Location]() 48 | 49 | /// Creates a new publishing context for a specific site, setting a root URL. 50 | /// - Parameters: 51 | /// - site: The site we're currently publishing. 52 | /// - rootURL: The URL of the root directory, where other key 53 | /// folders are located. 54 | /// - buildDirectoryPath: The path where the artifacts are generated. 55 | public init( 56 | for site: any Site, 57 | rootURL: URL, 58 | buildDirectoryPath: String 59 | ) throws { 60 | self.site = site 61 | 62 | rootDirectory = rootURL 63 | assetsDirectory = rootDirectory.appending(path: "Assets") 64 | contentDirectory = rootDirectory.appending(path: "Content") 65 | // includesDirectory = rootDirectory.appending(path: "Includes") 66 | buildDirectory = rootDirectory.appending(path: buildDirectoryPath) 67 | } 68 | 69 | /// Creates a new publishing context for a specific site, providing the path to 70 | /// one of the user's file. This then navigates upwards to find the root directory. 71 | /// - Parameters: 72 | /// - site: The site we're currently publishing. 73 | /// - file: One file from the user's package. 74 | /// - buildDirectoryPath: The path where the artifacts are generated. 75 | /// The default is "Build". 76 | init(for site: any Site, from file: StaticString, buildDirectoryPath: String) throws { 77 | let sourceBuildDirectories = try URL.selectDirectories(from: file) 78 | assert(sourceBuildDirectories.build == sourceBuildDirectories.source, "Detected Build and Source directories are not the same, so is this running as a Mac app? Fine, but was not expected.") 79 | try self.init( 80 | for: site, 81 | rootURL: sourceBuildDirectories.source, 82 | buildDirectoryPath: buildDirectoryPath 83 | ) 84 | } 85 | 86 | public func reportWarning( 87 | _ warning: String 88 | ) { 89 | warnings.append(warning) 90 | } 91 | } 92 | 93 | /// API for each step of the generation 94 | public extension Context { 95 | func loadContent( 96 | from loaders: sending [any ContentLoader] 97 | ) async throws { 98 | // TODO: this should be a concurrent map 99 | for loader in loaders { 100 | let loadedContent = try await loader.load(context: self) 101 | 102 | for content in loadedContent { 103 | if allContent.contains(where: { $0.path == content.path }) { 104 | throw PublishingError.duplicateContentWithSamePath(content.path) 105 | } 106 | allContent.append(content) 107 | } 108 | } 109 | } 110 | 111 | func mutateContent( 112 | path: String, 113 | as: T.Type, 114 | mutate: (inout T) -> Void 115 | ) { 116 | guard let index = allContent.firstIndex(where: { $0.path == path }) else { return } 117 | var content = allContent[index] as! T 118 | mutate(&content) 119 | allContent[index] = content 120 | } 121 | 122 | /// Removes all content from the Build folder, so we're okay to recreate it. 123 | func clearBuildFolder() throws(PublishingError) { 124 | do { 125 | try FileManager.default.removeItem(at: buildDirectory) 126 | } catch { 127 | print("Could not remove buildDirectory (\(buildDirectory)), but it will be re-created anyway.") 128 | } 129 | 130 | do { 131 | try FileManager.default.createDirectory(at: buildDirectory, withIntermediateDirectories: true) 132 | } catch { 133 | throw .failedToCreateBuildDirectory(buildDirectory) 134 | } 135 | } 136 | 137 | /// Renders the pages to the output folder. 138 | func generateStaticPages( 139 | pages: sending [any Page] 140 | ) async throws { 141 | for page in pages { 142 | try await render(page) 143 | } 144 | } 145 | 146 | /// Runs the given `PageProvider`s and renders all `Page`s returned from them. 147 | /// Renders the pages to the output folder. 148 | func generateContentPages( 149 | providers: sending [any PageProvider] 150 | ) async throws { 151 | for provider in providers { 152 | let pages = try await provider.source(context: self) 153 | try await generateStaticPages(pages: pages) 154 | } 155 | } 156 | 157 | private func render(_ staticPage: any Page) async throws { 158 | let outputString = try await staticPage.render(context: self) 159 | 160 | let outputDirectory = buildDirectory.appending(path: staticPage.path) 161 | 162 | try write( 163 | outputString, 164 | to: outputDirectory, 165 | fileName: staticPage.fileName, 166 | priority: staticPage.priority 167 | ) // isHomePage ? 1 : 0.9) 168 | } 169 | 170 | /// Writes a single string of data to a URL. 171 | /// - Parameters: 172 | /// - string: The string to write. 173 | /// - directory: The directory to write to. This has "index.html" 174 | /// appended to it, so users are directed to the correct page immediately. 175 | /// - priority: A priority value to control how important this content 176 | /// is for the sitemap. 177 | private func write( 178 | _ string: String, 179 | to directory: URL, 180 | fileName: String, 181 | priority: SitemapPriority 182 | ) throws { 183 | do { 184 | try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) 185 | } catch { 186 | throw PublishingError.failedToCreateBuildDirectory(directory) 187 | } 188 | 189 | let outputURL = directory.appending(path: fileName) 190 | 191 | // Check the sitemap 192 | let siteMapPath = if fileName == "index.html" { 193 | directory.relative(to: buildDirectory) 194 | } else { 195 | outputURL.relative(to: buildDirectory) 196 | } 197 | if siteMap.contains(siteMapPath) { 198 | throw PublishingError.duplicateContentWithSamePath(siteMapPath) 199 | } 200 | 201 | do { 202 | try string.write(to: outputURL, atomically: true, encoding: .utf8) 203 | 204 | // Add to sitemap 205 | if priority != .hidden { 206 | siteMap.append(Location(path: siteMapPath, priority: priority.value)) 207 | } 208 | } catch { 209 | throw PublishingError.failedToCreateBuildFile(outputURL) 210 | } 211 | } 212 | 213 | /// Copy the assets in the `assetsDirectory` to the `buildDirectory. 214 | func copyAssets() throws { 215 | let assets = try FileManager.default.contentsOfDirectory( 216 | at: assetsDirectory, 217 | includingPropertiesForKeys: nil 218 | ) 219 | 220 | for asset in assets { 221 | try FileManager.default.copyItem( 222 | at: assetsDirectory.appending(path: asset.lastPathComponent), 223 | to: buildDirectory.appending(path: asset.lastPathComponent) 224 | ) 225 | } 226 | } 227 | 228 | func checkWarnings() { 229 | if warnings.isEmpty == false { 230 | print("Publish completed with warnings:") 231 | print(warnings.map { "\t- \($0)" }.joined(separator: "\n")) 232 | } 233 | } 234 | } 235 | 236 | public enum SitemapPriority: Sendable { 237 | /// Priority of 1 238 | case highest 239 | /// Defaults to 0.5 240 | case `default` 241 | /// Priority of 0.4 242 | case low 243 | /// Indicates the location shouldn't be included in the sitemap 244 | case hidden 245 | 246 | var value: Double { 247 | switch self { 248 | case .highest: 249 | 1 250 | case .default: 251 | 0.5 252 | case .low: 253 | 0.4 254 | case .hidden: 255 | 0 // check if it's hidden before calling this property 256 | } 257 | } 258 | } 259 | 260 | /// API so pages can access the loaded content 261 | public extension Context { 262 | func content(of type: T.Type) -> [T] { 263 | // could memoize? 264 | allContent.compactMap { $0 as? T } 265 | } 266 | 267 | // for when you want to filter to a protocol 268 | // it should inherit from Content protocol, but not sure how to pull it off with the type system right now since constrainint it means that you can't pass the protocol.sself since that doesn't conform to the protocol. 269 | func content(of type: T.Type) -> [T] { 270 | allContent.compactMap { $0 as? T } 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /Sources/Genesis/Core/Page.swift: -------------------------------------------------------------------------------- 1 | 2 | /// A single `Page` in the site. 3 | public protocol Page: Sendable { 4 | /// From the output folder. Like /blog/today 5 | var path: String { get } 6 | /// Name for the file to put in the path. Like "index.html", the default 7 | var fileName: String { get } 8 | 9 | var priority: SitemapPriority { get } 10 | 11 | func render(context: Context) async throws -> String 12 | } 13 | 14 | public extension Page { 15 | /// The default file name, can be customized for sitemaps or feeds. 16 | var fileName: String { "index.html" } 17 | 18 | /// The default site map priority of all pages 19 | var priority: SitemapPriority { .default } 20 | } 21 | 22 | /// A provider that reads content from the `Context` to create pages. 23 | public protocol PageProvider { 24 | func source(context: Context) async throws -> [any Page] 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Genesis/Core/Site.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public protocol Site: Sendable { 4 | /// The author of your site, which should be your name. 5 | /// Defaults to an empty string. 6 | var author: String { get } 7 | 8 | /// A string to append to the end of your page titles. For example, if you have 9 | /// a page titled "About Me" and a site title suffix of " – My Awesome Site", then 10 | /// your rendered page title will be "About Me – My Awesome Site". 11 | /// Defaults to an empty string. 12 | // var titleSuffix: String { get } 13 | 14 | /// The name of your site. Required. 15 | var name: String { get } 16 | 17 | /// An optional description for your site. Defaults to nil. 18 | var description: String? { get } 19 | 20 | /// The language your site is published in. Defaults to `.en`. 21 | var language: Language { get } 22 | 23 | /// The base URL for your site, e.g. https://www.example.com 24 | var url: URL { get } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Genesis/Extensions/Date-RFC822.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension Date { 4 | /// Converts `Date` objects to RFC-822 format, which is used by RSS. 5 | var asRFC822: String { 6 | let formatter = DateFormatter() 7 | formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss Z" 8 | return formatter.string(from: self) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sources/Genesis/Extensions/String-AbsoluteLinks.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension String { 4 | /// Converts links and image sources from relative links to absolute. 5 | /// - Parameter url: The base URL, which is usually your web domain. 6 | /// - Returns: The adjusted string, where all relative links are absolute. 7 | func makingAbsoluteLinks( 8 | relativeTo url: URL, 9 | root: URL 10 | ) -> String { 11 | var absolute = self 12 | 13 | // Fix images. 14 | absolute.replace(#/src="(?!http)(?!\/)/#, with: #"src="\#(url)/"#) 15 | 16 | // absolute.replace(#/src="(?!http)(?!\/)/#) { match in 17 | // let fullURL = url.appending(path: match.output.path).absoluteString 18 | // return "src=\"\(fullURL)" 19 | // } 20 | 21 | // Fix links. 22 | // Replace links that are full relative (without /) 23 | absolute.replace(#/href="(?!http)(?!\/)/#, with: #"href="\#(url)/"#) 24 | // Replace links that are root relative (with /) 25 | absolute.replace(#/href="(?!http)(\/)/#, with: #"href="\#(root)/"#) 26 | 27 | // absolute.replace(#/href="(?\/[^"]+)/#) { match in 28 | // let fullURL = url.appending(path: match.output.path).absoluteString 29 | // return "href=\"\(fullURL)" 30 | // } 31 | 32 | return absolute 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Genesis/Extensions/String-Slug.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension String { 4 | /// A list of characters that are safe to use in URLs. 5 | private static let slugSafeCharacters = CharacterSet(charactersIn: """ 6 | 0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\ 7 | abcdefghijklmnopqrstuvwxyz- 8 | """) 9 | 10 | /// Attempts to convert a string to a URL-safe format. 11 | /// - Returns: The URL-safe version of the string, or nil if no 12 | /// conversion was possible. 13 | func convertedToSlug() -> String? { 14 | let startingPoint = convertToDashCase() 15 | 16 | var result: String? 17 | 18 | if let latin = startingPoint.applyingTransform( 19 | StringTransform("Any-Latin; Latin-ASCII; Lower;"), 20 | reverse: false 21 | ) { 22 | let urlComponents = latin.components(separatedBy: String.slugSafeCharacters.inverted) 23 | result = urlComponents.filter { $0 != "" }.joined(separator: "-") 24 | } 25 | 26 | if let result { 27 | if result.isEmpty == false { 28 | // Replace multiple dashes with a single dash. 29 | return result.replacing(#/-{2,}/#, with: "-") 30 | } 31 | } 32 | 33 | return nil 34 | } 35 | 36 | /// Takes a string in CamelCase and converts it to 37 | /// snake-case. 38 | /// - Returns: The provided string, converted to snake case. 39 | func convertToDashCase() -> String { 40 | var result = "" 41 | 42 | for (index, character) in enumerated() { 43 | if character.isUppercase && index != 0 { 44 | result += "-" 45 | } 46 | 47 | result += String(character) 48 | } 49 | 50 | return result.lowercased() 51 | } 52 | } 53 | 54 | public extension String { 55 | func normalized() -> String { 56 | String(lowercased().compactMap { character in 57 | if character.isWhitespace { 58 | return "-" 59 | } 60 | 61 | if character.isLetter || character.isNumber { 62 | return character 63 | } 64 | 65 | return nil 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Genesis/Extensions/String-StrippingTags.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension String { 4 | /// Removes all HTML tags from a string, so it's safe to use as plain-text. 5 | func strippingTags() -> String { 6 | replacing(#/<.*?>/#, with: "") 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Genesis/Extensions/URL+Date.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension URL { 4 | var fileCreationDate: Date { 5 | let fromFile = try? resourceValues(forKeys: [.creationDateKey]).creationDate 6 | return fromFile ?? .now 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sources/Genesis/Extensions/URL-Relative.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension URL { 4 | /// Creates a relative URL by using another URL as its base. 5 | /// - Parameter other: The base URL to compare against. 6 | /// - Returns: A relative URL. 7 | func relative(to other: URL) -> String { 8 | let basePath = other.path() 9 | let thisPath = path() 10 | let result = thisPath.trimmingPrefix(basePath) 11 | 12 | return String(result) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Genesis/Extensions/URL-SelectDirectories.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public extension URL { 4 | /// Returns URL where to find Assets/Content/Includes and URL where to generate the static web site. 5 | /// It supports running the generation from source via `swift run` or from a precompiled binary. 6 | /// When building a package, the website is built at the source URL (and both URLs are equal). 7 | /// When building a MacOS app, the website is built in a subdirectory of the app's sandbox. 8 | /// - Parameter file: path of a Swift source file to find source root directory by scanning path upwards. 9 | /// - Returns tupple containing source URL and URL where output is built. 10 | static func selectDirectories(from file: StaticString) throws -> SourceBuildDirectories { 11 | // From swift run 12 | var currentURL = URL(filePath: file.description) 13 | repeat { 14 | currentURL = currentURL.deletingLastPathComponent() 15 | 16 | let packageURL = currentURL.appending(path: "Package.swift") 17 | if FileManager.default.fileExists(atPath: packageURL.path) { 18 | return SourceBuildDirectories(source: packageURL.deletingLastPathComponent(), 19 | build: packageURL.deletingLastPathComponent()) 20 | } 21 | } while currentURL.path() != "/" 22 | 23 | // When build as binary 24 | currentURL = URL(filePath: FileManager.default.currentDirectoryPath) 25 | while currentURL.path() != "/" { 26 | let packageURL = currentURL.appending(path: "Package.swift") 27 | if FileManager.default.fileExists(atPath: packageURL.path) { 28 | return SourceBuildDirectories(source: packageURL.deletingLastPathComponent(), 29 | build: packageURL.deletingLastPathComponent()) 30 | } 31 | 32 | currentURL = currentURL.deletingLastPathComponent() 33 | } 34 | 35 | let buildDirectory: String = NSHomeDirectory() // app's home directory for a sandboxed MacOS app 36 | if buildDirectory.contains("/Library/Containers/") { 37 | let buildDirectoryURL = URL(filePath: buildDirectory) 38 | return SourceBuildDirectories(source: buildDirectoryURL, 39 | build: buildDirectoryURL) 40 | } 41 | 42 | throw PublishingError.missingPackageDirectory 43 | } 44 | } 45 | 46 | /// Provides URL to where to find Assets/Content/Includes input directories and Build output directory 47 | public struct SourceBuildDirectories { 48 | public let source: URL 49 | public let build: URL 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Genesis/SecondaryCore/Language.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // It's difficult to find a list of actual language codes 4 | // supported in web pages, so the list below is a composite 5 | // of several sources in order to provide maximum flexibility. 6 | // You'd think that RFC 5646 would be important enough to list 7 | // all these somewhere, but apparently not! 8 | // Source 1: https://stackoverflow.com/questions/3217492 9 | // Source 2: https://stackoverflow.com/questions/3191664 10 | // Source 3: https://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt 11 | 12 | // swiftlint:disable type_body_length 13 | /// An enum providing RFC-5646 language codes used for web pages. 14 | public enum Language: String, Sendable { 15 | case abkhaz = "ab" 16 | case afar = "aa" 17 | case afrikaans = "af" 18 | case akan = "ak" 19 | case albanian = "sq" 20 | case amharic = "am" 21 | case arabic = "ar" 22 | case arabicAlgeria = "ar-DZ" 23 | case arabicBahrain = "ar-BH" 24 | case arabicEgypt = "ar-EG" 25 | case arabicIraq = "ar-IQ" 26 | case arabicJordan = "ar-JO" 27 | case arabicKuwait = "ar-KW" 28 | case arabicLebanon = "ar-LB" 29 | case arabicLibya = "ar-LY" 30 | case arabicMorocco = "ar-MA" 31 | case arabicOman = "ar-OM" 32 | case arabicQatar = "ar-QA" 33 | case arabicSaudiArabia = "ar-SA" 34 | case arabicSyria = "ar-SY" 35 | case arabicTunisia = "ar-TN" 36 | case arabicUAE = "ar-AE" 37 | case arabicYemen = "ar-YE" 38 | case aragonese = "an" 39 | case armenian = "hy" 40 | case assamese = "as" 41 | case avaric = "av" 42 | case avestan = "ae" 43 | case aymara = "ay" 44 | case azerbaijani = "az" 45 | case bambara = "bm" 46 | case bashkir = "ba" 47 | case basque = "eu" 48 | case belarusian = "be" 49 | case bengali = "bn" 50 | case bihari = "bh" 51 | case bislama = "bi" 52 | case bosnian = "bs" 53 | case breton = "br" 54 | case bulgarian = "bg" 55 | case burmese = "my" 56 | case catalan = "ca" 57 | case chamorro = "ch" 58 | case chechen = "ce" 59 | case chichewa, chewa, nyanja = "ny" 60 | case chinese = "zh" 61 | case chineseSimplified = "zh-CN" 62 | case chineseTraditional = "zh-TW" 63 | case chuvash = "cv" 64 | case cornish = "kw" 65 | case corsican = "co" 66 | case cree = "cr" 67 | case croatian = "hr" 68 | case czech = "cs" 69 | case danish = "da" 70 | case divehi, dhivehi, maldivian = "dv" 71 | case dutch = "nl" 72 | case dutchBelgium = "nl-BE" 73 | case dutchNetherlands = "nl-NL" 74 | case dzongkha = "dz" 75 | case english = "en" 76 | case englishAustralia = "en-AU" 77 | case englishBelize = "en-BZ" 78 | case englishCanada = "en-CA" 79 | case englishCaribbean = "en-CB" 80 | case englishIreland = "en-IE" 81 | case englishJamaica = "en-JM" 82 | case englishNewZealand = "en-NZ" 83 | case englishPhilippines = "en-PH" 84 | case englishSouthAfrica = "en-ZA" 85 | case englishTrinidadAndTobago = "en-TT" 86 | case englishUnitedKingdom = "en-GB" 87 | case englishUnitedStates = "en-US" 88 | case englishZimbabwe = "en-ZW" 89 | case esperanto = "eo" 90 | case estonian = "et" 91 | case ewe = "ee" 92 | case faroese = "fo" 93 | case fijian = "fj" 94 | case finnish = "fi" 95 | case french = "fr" 96 | case frenchBelgium = "fr-BE" 97 | case frenchCanada = "fr-CA" 98 | case frenchFrance = "fr-FR" 99 | case frenchLuxembourg = "fr-LU" 100 | case frenchMonaco = "fr-MC" 101 | case frenchSwitzerland = "fr-CH" 102 | case fula, fulah, pulaar, pular = "ff" 103 | case galician = "gl" 104 | case ganda = "lg" 105 | case georgian = "ka" 106 | case german = "de" 107 | case germanAustria = "de-AT" 108 | case germanSwitzerland = "de-CH" 109 | case germanGermany = "de-DE" 110 | case germanLiechtenstein = "de-LI" 111 | case germanLuxembourg = "de-LU" 112 | case greek = "el" 113 | case guarani = "gn" 114 | case gujarati = "gu" 115 | case haitian = "ht" 116 | case hausa = "ha" 117 | case hebrew = "he" 118 | case herero = "hz" 119 | case hindi = "hi" 120 | case hiriMotu = "ho" 121 | case hungarian = "hu" 122 | case icelandic = "is" 123 | case ido = "io" 124 | case igbo = "ig" 125 | case indonesian = "id" 126 | case interlingua = "ia" 127 | case interlingue = "ie" 128 | case inuktitut = "iu" 129 | case inupiaq = "ik" 130 | case irish = "ga" 131 | case italian = "it" 132 | case italianSwitzerland = "it-CH" 133 | case italianItaly = "it-IT" 134 | case japanese = "ja" 135 | case javanese = "jv" 136 | case kalaallisut, greenlandic = "kl" 137 | case kannada = "kn" 138 | case kanuri = "kr" 139 | case kashmiri = "ks" 140 | case kazakh = "kk" 141 | case khmer = "km" 142 | case kikuyu, gikuyu = "ki" 143 | case kinyarwanda = "rw" 144 | case kirundi = "rn" 145 | case komi = "kv" 146 | case kongo = "kg" 147 | case korean = "ko" 148 | case kurdish = "ku" 149 | case kwanyama, kuanyama = "kj" 150 | case kyrgyz = "ky" 151 | case lao = "lo" 152 | case latin = "la" 153 | case latvian = "lv" 154 | case limburgish = "li" 155 | case lingala = "ln" 156 | case lithuanian = "lt" 157 | case lubaKatanga = "lu" 158 | case luxembourgish, letzeburgesch = "lb" 159 | case macedonian = "mk" 160 | case malagasy = "mg" 161 | case malay = "ms" 162 | case malayBrunei = "ms-BN" 163 | case malayMalaysia = "ms-MY" 164 | case malayalam = "ml" 165 | case maltese = "mt" 166 | case manx = "gv" 167 | case maori = "mi" 168 | case marathi = "mr" 169 | case marshallese = "mh" 170 | case mongolian = "mn" 171 | case nauru = "na" 172 | case navajo = "nv" 173 | case ndonga = "ng" 174 | case nepali = "ne" 175 | case northernSami = "se" 176 | case northNdebele = "nd" 177 | case norwegian = "no" 178 | case norwegianBokmal = "nb" 179 | case norwegianNynorsk = "nn" 180 | case nuosu = "ii" 181 | case occitan = "oc" 182 | case ojibwe, ojibwa = "oj" 183 | case oldChurchSlavonic, 184 | churchSlavonic, 185 | oldBulgarian, 186 | oldChurchSlavic, 187 | oldSlavonic = "cu" 188 | case oriya = "or" 189 | case oromo = "om" 190 | case ossetian, ossetic = "os" 191 | case pali = "pi" 192 | case panjabi, punjabi = "pa" 193 | case pashto, pushto = "ps" 194 | case persian = "fa" 195 | case polish = "pl" 196 | case portuguese = "pt" 197 | case portugueseBrazil = "pt-BR" 198 | case portuguesePortugal = "pt-PT" 199 | case quechua = "qu" 200 | case romanian, moldavian, moldovan = "ro" 201 | case romansh = "rm" 202 | case russian = "ru" 203 | case samoan = "sm" 204 | case sango = "sg" 205 | case sanskrit = "sa" 206 | case sardinian = "sc" 207 | case scottishGaelic, gaelic = "gd" 208 | case serbian = "sr" 209 | case shona = "sn" 210 | case sindhi = "sd" 211 | case sinhala, sinhalese = "si" 212 | case slovak = "sk" 213 | case slovene = "sl" 214 | case somali = "so" 215 | case southernSotho = "st" 216 | case southNdebele = "nr" 217 | case spanish = "es" 218 | case spanishArgentina = "es-AR" 219 | case spanishBolivia = "es-BO" 220 | case spanishChile = "es-CL" 221 | case spanishColombia = "es-CO" 222 | case spanishCostaRica = "es-CR" 223 | case spanishDominicanRepublic = "es-DO" 224 | case spanishEcuador = "es-EC" 225 | case spanishSpain = "es-ES" 226 | case spanishGuatemala = "es-GT" 227 | case spanishHonduras = "es-HN" 228 | case spanishMexico = "es-MX" 229 | case spanishNicaragua = "es-NI" 230 | case spanishPanama = "es-PA" 231 | case spanishPeru = "es-PE" 232 | case spanishPuertoRico = "es-PR" 233 | case spanishParaguay = "es-PY" 234 | case spanishElSalvador = "es-SV" 235 | case spanishUruguay = "es-UY" 236 | case spanishVenezuela = "es-VE" 237 | case sundanese = "su" 238 | case swahili = "sw" 239 | case swati = "ss" 240 | case swedish = "sv" 241 | case swedishFinland = "sv-FI" 242 | case swedishSweden = "sv-SE" 243 | case tagalog = "tl" 244 | case tahitian = "ty" 245 | case tajik = "tg" 246 | case tamil = "ta" 247 | case tatar = "tt" 248 | case telugu = "te" 249 | case thai = "th" 250 | case tibetan = "bo" 251 | case tigrinya = "ti" 252 | case tongan = "to" 253 | case tsonga = "ts" 254 | case tswana = "tn" 255 | case turkish = "tr" 256 | case turkmen = "tk" 257 | case twi = "tw" 258 | case ukrainian = "uk" 259 | case urdu = "ur" 260 | case uyghur, uighur = "ug" 261 | case uzbek = "uz" 262 | case venda = "ve" 263 | case vietnamese = "vi" 264 | case volapuk = "vo" 265 | case walloon = "wa" 266 | case welsh = "cy" 267 | case westernFrisian = "fy" 268 | case wolof = "wo" 269 | case xhosa = "xh" 270 | case yiddish = "yi" 271 | case yoruba = "yo" 272 | case zhuang, chuang = "za" 273 | case zulu = "zu" 274 | } 275 | 276 | // swiftlint:enable type_body_length 277 | -------------------------------------------------------------------------------- /Sources/Genesis/SecondaryCore/Location.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// A location that can be written into our sitemap. 4 | public struct Location: Sendable { 5 | public var path: String 6 | public var priority: Double 7 | } 8 | 9 | extension Array { 10 | /// An extension that lets us determine whether one path is contained inside 11 | /// An array of `Location` objects. 12 | func contains(_ path: String) -> Bool { 13 | contains { 14 | $0.path == path 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Genesis/SecondaryCore/PublishingError.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// All the primary errors that can occur when publishing a site. There are other 4 | /// errors that can be triggered, but they are handled through fatalError() because 5 | /// something is seriously wrong. 6 | public enum PublishingError: LocalizedError { 7 | case duplicateContentWithSamePath(String) 8 | 9 | /// Could not find the site's package directory. 10 | case missingPackageDirectory 11 | 12 | /// Invalid Markdown was found at the specific URL. 13 | case badMarkdown(URL) 14 | 15 | /// A file cannot be opened (bad encoding, etc). 16 | case unopenableFile(String) 17 | 18 | /// Publishing attempted to remove the build directory, but failed. 19 | case failedToRemoveBuildDirectory(URL) 20 | 21 | /// Publishing attempted to create the build directory, but failed. 22 | case failedToCreateBuildDirectory(URL) 23 | 24 | /// Publishing attempted to create a file during a build, but failed. 25 | case failedToCreateBuildFile(URL) 26 | 27 | /// Failed to locate one of the key Ignite resources. 28 | case missingSiteResource(String) 29 | 30 | /// Publishing attempted to copy one of the key site resources during a 31 | /// build, but failed. 32 | case failedToCopySiteResource(String) 33 | 34 | /// A syntax highlighter file resource was not found. 35 | case missingSyntaxHighlighter(String) 36 | 37 | /// A syntax highlighter file resource could not be loaded. 38 | case failedToLoadSyntaxHighlighter(String) 39 | 40 | /// Publishing attempted to write out the syntax highlighter data during a 41 | /// build, but failed. 42 | case failedToWriteSyntaxHighlighters 43 | 44 | /// Publishing attempted to write out the RSS feed during a build, but failed. 45 | case failedToWriteFeed 46 | 47 | /// Publishing attempted to write out the robots.txt file during a build, but failed. 48 | case failedToWriteRobots 49 | 50 | /// A Markdown file requested a named layout that does not exist. 51 | case missingNamedLayout(String) 52 | 53 | /// Site attempted to render Markdown content without a layout in place. 54 | case missingDefaultLayout 55 | 56 | /// Publishing attempted to write a file at the specific URL. but it already exists. 57 | case duplicateDirectory(URL) 58 | 59 | /// Converts all errors to a string for easier reading. 60 | public var errorDescription: String? { 61 | switch self { 62 | case .missingPackageDirectory: 63 | "Unable to locate Package.swift." 64 | case let .badMarkdown(url): 65 | "Markdown could not be parsed: \(url.absoluteString)." 66 | case let .unopenableFile(reason): 67 | "Failed to open file: \(reason)." 68 | case let .failedToRemoveBuildDirectory(url): 69 | "Failed to clear the build folder: \(url.absoluteString)." 70 | case let .failedToCreateBuildDirectory(url): 71 | "Failed to create the build folder: \(url.absoluteString)." 72 | case let .failedToCreateBuildFile(url): 73 | "Failed to create the build folder: \(url.absoluteString)." 74 | case let .missingSiteResource(name): 75 | "Failed to locate critical site resource: \(name)." 76 | case let .failedToCopySiteResource(name): 77 | "Failed to copy critical site resource to build folder: \(name)." 78 | case let .missingSyntaxHighlighter(name): 79 | "Failed to locate syntax highlighter JavaScript: \(name)." 80 | case let .failedToLoadSyntaxHighlighter(name): 81 | "Failed to load syntax highlighter JavaScript: \(name)." 82 | case .failedToWriteSyntaxHighlighters: 83 | "Failed to write syntax highlighting JavaScript." 84 | case .failedToWriteFeed: 85 | "Failed to generate RSS feed." 86 | case .failedToWriteRobots: 87 | "Failed to write robots.txt file." 88 | case let .missingNamedLayout(name): 89 | "Failed to find layout named \(name)." 90 | case .missingDefaultLayout: 91 | "Your site must provide at least one layout in order to render Markdown." 92 | case let .duplicateDirectory(url): 93 | "Duplicate URL found: \(url). This is a fatal error." 94 | case let .duplicateContentWithSamePath(path): 95 | "Duplicated content loaded: \(path)" 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/GenesisMarkdown/MarkdownToHTML.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Markdown 3 | 4 | public struct ParsedMarkdown: Sendable { 5 | public enum Error: Swift.Error { 6 | case unopenableFile(String) 7 | case badMarkdown(URL) 8 | } 9 | 10 | /// A dictionary of metadata specified at the top of the file as YAML front matter. 11 | /// See https://jekyllrb.com/docs/front-matter/ for information. 12 | public var frontMatter = [String: String]() 13 | 14 | /// The title of this document. 15 | public var title: String 16 | 17 | /// The description of this document, which is the first paragraph. 18 | public var description: String 19 | 20 | /// The body text of this file, which includes its title by default. 21 | public var body: String 22 | 23 | public var tags: [String] { 24 | guard let tagsString = frontMatter["tags"] else { 25 | return [] 26 | } 27 | return tagsString.split(separator: "','").map(String.init) 28 | } 29 | 30 | /// Original Markdown document tree. Can be manipulated at any stage to recreate the body. 31 | public nonisolated(unsafe) let document: Document 32 | 33 | /// Parses Markdown provided from a filesystem URL. 34 | /// - Parameters: 35 | /// - url: The filesystem URL to load. 36 | public init( 37 | parsing fileURL: URL 38 | ) throws(Error) { 39 | var markdown: String 40 | do { 41 | markdown = try String(contentsOf: fileURL) 42 | } catch { 43 | throw .unopenableFile(error.localizedDescription) 44 | } 45 | frontMatter = Self.processMetadata(for: &markdown) 46 | document = Document(parsing: markdown) 47 | let visitor = MarkdownToHTML(document: document, removeTitleFromBody: true) 48 | title = frontMatter["title"] ?? visitor.title 49 | description = visitor.description 50 | body = visitor.body 51 | } 52 | 53 | /// Looks for and parses any YAML front matter from this Markdown. 54 | /// - Parameter markdown: The Markdown string to process. The remaining Markdown, once front matter has been removed. 55 | /// - Returns: The front-matter 56 | private static func processMetadata(for markdown: inout String) -> [String: String] { 57 | var frontMatter = [String: String]() 58 | if markdown.starts(with: "---") { 59 | let parts = markdown.split(separator: "---", maxSplits: 1, omittingEmptySubsequences: true) 60 | 61 | let header = parts[0].split(separator: "\n", omittingEmptySubsequences: true) 62 | 63 | for entry in header { 64 | let entryParts = entry.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: true) 65 | guard entryParts.count == 2 else { continue } 66 | 67 | let key = entryParts[0].trimmingCharacters(in: .whitespaces) 68 | var value = entryParts[1].trimmingCharacters(in: .whitespaces) 69 | 70 | if key == "tags" { 71 | // print(key, value) 72 | 73 | // let prevValue = value 74 | // Poor way of supporting tag arrays 75 | value = value.replacingOccurrences(of: "['", with: "") 76 | .replacingOccurrences(of: "']", with: "") 77 | // keep ',' to split them later 78 | .replacingOccurrences(of: "', '", with: "','") 79 | // .replacingOccurrences(of: "','", with: ", ") 80 | // .replacingOccurrences(of: "'", with: "") 81 | 82 | // print(">>\(prevValue)<<>>\(value)<<") 83 | } 84 | 85 | frontMatter[key] = value 86 | } 87 | 88 | markdown = String(parts[1].trimmingCharacters(in: .whitespacesAndNewlines)) 89 | } 90 | return frontMatter 91 | } 92 | } 93 | 94 | /// A simple Markdown to HTML parser powered by Apple's swift-markdown. 95 | public struct MarkdownToHTML: MarkupVisitor { 96 | /// The title of this document. 97 | public var title = "" 98 | 99 | /// The description of this document, which is the first paragraph. 100 | public var description = "" 101 | 102 | /// The body text of this file, which includes its title by default. 103 | public var body = "" 104 | 105 | /// Whether to remove the Markdown title from its body. This only applies 106 | /// to the first heading. 107 | public var removeTitleFromBody: Bool 108 | 109 | /// Parses Markdown provided as a direct input string. 110 | /// - Parameters: 111 | /// - markdown: The Markdown to parse. 112 | /// - removeTitleFromBody: True if the first title should be removed 113 | /// from the final `body` property. 114 | public init( 115 | document: Document, 116 | removeTitleFromBody: Bool 117 | ) { 118 | self.removeTitleFromBody = removeTitleFromBody 119 | body = visit(document) 120 | } 121 | 122 | /// Visit some markup when no other handler is suitable. 123 | /// - Parameter markup: The markup that is being processed. 124 | /// - Returns: A string to append to the output. 125 | public mutating func defaultVisit(_ markup: Markdown.Markup) -> String { 126 | var result = "" 127 | 128 | for child in markup.children { 129 | result += visit(child) 130 | } 131 | 132 | return result 133 | } 134 | 135 | /// Processes block quote markup. 136 | /// - Parameter blockQuote: The block quote data to process. 137 | /// - Returns: A HTML
    element with the block quote's children inside. 138 | public mutating func visitBlockQuote(_ blockQuote: Markdown.BlockQuote) -> String { 139 | var result = "
    " 140 | 141 | for child in blockQuote.children { 142 | result += visit(child) 143 | } 144 | 145 | result += "
    " 146 | return result 147 | } 148 | 149 | /// Processes code block markup. 150 | /// - Parameter codeBlock: The code block to process. 151 | /// - Returns: A HTML
     element with  inside, marked with
    152 |     /// CSS to remember which language was used.
    153 |     public func visitCodeBlock(_ codeBlock: Markdown.CodeBlock) -> String {
    154 |         let escapedCode = codeBlock.code.poorHtmlEncoded()
    155 |         return if let language = codeBlock.language {
    156 |             #"
    \#(escapedCode)
    "# 157 | } else { 158 | #"
    \#(escapedCode)
    "# 159 | } 160 | } 161 | 162 | /// Processes emphasis markup. 163 | /// - Parameter emphasis: The emphasized content to process. 164 | /// - Returns: A HTML element with the markup's children inside. 165 | public mutating func visitEmphasis(_ emphasis: Markdown.Emphasis) -> String { 166 | var result = "" 167 | 168 | for child in emphasis.children { 169 | result += visit(child) 170 | } 171 | 172 | result.append("") 173 | return result 174 | } 175 | 176 | /// Processes heading markup. 177 | /// - Parameter heading: The heading to process. 178 | /// - Returns: A HTML element with its children inside. The exact 179 | /// heading level depends on the markup. If this is our first heading, we use it 180 | /// for the document title. 181 | public mutating func visitHeading(_ heading: Markdown.Heading) -> String { 182 | var headingContent = "" 183 | 184 | for child in heading.children { 185 | headingContent += visit(child) 186 | } 187 | 188 | // If we don't already have a document title, use this as the document's title. 189 | if title.isEmpty { 190 | title = headingContent 191 | 192 | // If we've been asked to strip out the title from 193 | // the rendered body, send back nothing here. 194 | if removeTitleFromBody { 195 | return "" 196 | } 197 | } 198 | 199 | // Create a header identifier so content can link with #id 200 | let id = headingContent 201 | .trimmingCharacters(in: .whitespaces) 202 | .replacingOccurrences(of: " ", with: "-") 203 | .lowercased() 204 | 205 | return #"\#(headingContent)"# 206 | } 207 | 208 | /// Processes a block of HTML markup. 209 | /// - Parameter html: The HTML to process. 210 | /// - Returns: The raw HTML as-is. 211 | public func visitHTMLBlock(_ html: Markdown.HTMLBlock) -> String { 212 | html.rawHTML 213 | } 214 | 215 | /// Process image markup. 216 | /// - Parameter image: The image markup to process. 217 | /// - Returns: A HTML tag with its source set correctly. This also 218 | /// appends Bootstrap's `img-fluid` CSS class so that images resize. 219 | public func visitImage(_ image: Markdown.Image) -> String { 220 | if let source = image.source { 221 | let title = image.plainText 222 | return #"\#(title)"# 223 | } else { 224 | return "" 225 | } 226 | } 227 | 228 | /// Process inline code markup. 229 | /// - Parameter inlineCode: The inline code markup to process. 230 | /// - Returns: A HTML tag containing the code. 231 | public mutating func visitInlineCode(_ inlineCode: Markdown.InlineCode) -> String { 232 | "\(inlineCode.code)" 233 | } 234 | 235 | /// Processes a chunk of inline HTML markup. 236 | /// - Parameter inlineHTML: The HTML markup to process. 237 | /// - Returns: The raw HTML as-is. 238 | public func visitInlineHTML(_ inlineHTML: Markdown.InlineHTML) -> String { 239 | inlineHTML.rawHTML 240 | } 241 | 242 | /// Processes hyperlink markup. 243 | /// - Parameter link: The link markup to process. 244 | /// - Returns: Returns a HTML tag with the correct location and content. 245 | public mutating func visitLink(_ link: Markdown.Link) -> String { 246 | var result = #""# 247 | 248 | for child in link.children { 249 | result += visit(child) 250 | } 251 | 252 | result += "" 253 | return result 254 | } 255 | 256 | /// Processes one item from a list. 257 | /// - Parameter listItem: The list item markup to process. 258 | /// - Returns: A HTML
  • tag containing the list item's contents. 259 | public mutating func visitListItem(_ listItem: Markdown.ListItem) -> String { 260 | var result = "
  • " 261 | 262 | for child in listItem.children { 263 | result += visit(child) 264 | } 265 | 266 | result += "
  • " 267 | return result 268 | } 269 | 270 | /// Processes unordered list markup. 271 | /// - Parameter orderedList: The unordered list markup to process. 272 | /// - Returns: A HTML
      element with the correct contents. 273 | public mutating func visitOrderedList(_ orderedList: Markdown.OrderedList) -> String { 274 | var result = "
        " 275 | 276 | for listItem in orderedList.listItems { 277 | result += visit(listItem) 278 | } 279 | 280 | result += "
      " 281 | return result 282 | } 283 | 284 | /// Processes a paragraph of text. 285 | /// - Parameter paragraph: The paragraph markup to process. 286 | /// - Returns: If we're inside a list this sends back the paragraph's 287 | /// contents directly. Otherwise, it wraps the contents in a HTML

      element. 288 | /// If this is the first paragraph of text in the document we use it for the 289 | /// description of this document. 290 | public mutating func visitParagraph(_ paragraph: Markdown.Paragraph) -> String { 291 | var result = "" 292 | var paragraphContents = "" 293 | 294 | if paragraph.isInsideList == false { 295 | result += "

      " 296 | } 297 | 298 | for child in paragraph.children { 299 | paragraphContents += visit(child) 300 | } 301 | 302 | result += paragraphContents 303 | 304 | if description.isEmpty { 305 | description = paragraphContents 306 | } 307 | 308 | if paragraph.isInsideList == false { 309 | result += "

      " 310 | } 311 | 312 | return result 313 | } 314 | 315 | /// Processes some strikethrough markup. 316 | /// - Parameter strikethrough: The strikethrough markup to process. 317 | /// - Returns: Content wrapped inside a HTML element. 318 | public mutating func visitStrikethrough(_ strikethrough: Markdown.Strikethrough) -> String { 319 | var result = "" 320 | 321 | for child in strikethrough.children { 322 | result += visit(child) 323 | } 324 | 325 | result += "" 326 | 327 | return result 328 | } 329 | 330 | /// Processes some strong markup. 331 | /// - Parameter strong: The strong markup to process. 332 | /// - Returns: Content wrapped inside a HTML element. 333 | public mutating func visitStrong(_ strong: Markdown.Strong) -> String { 334 | var result = "" 335 | 336 | for child in strong.children { 337 | result += visit(child) 338 | } 339 | 340 | result += "" 341 | return result 342 | } 343 | 344 | /// Processes table markup. 345 | /// - Parameter table: The table markup to process. 346 | /// - Returns: A HTML element, optionally with and 347 | /// if they are provided. 348 | public mutating func visitTable(_ table: Markdown.Table) -> String { 349 | var output = "
      " 350 | 351 | if table.head.childCount > 0 { 352 | output += "" 353 | output += visit(table.head) 354 | output += "" 355 | } 356 | 357 | if table.body.childCount > 0 { 358 | output += "" 359 | output += visit(table.body) 360 | output += "" 361 | } 362 | 363 | output += "
      " 364 | return output 365 | } 366 | 367 | /// Processes table head markup. 368 | /// - Parameter tableHead: The table head markup to process. 369 | /// - Returns: A string containing zero or more HTML elements 370 | /// representing the headers in this table. 371 | public mutating func visitTableHead(_ tableHead: Markdown.Table.Head) -> String { 372 | var output = "" 373 | 374 | for child in tableHead.children { 375 | output += "" 376 | output += visit(child) 377 | output += "" 378 | } 379 | 380 | return output 381 | } 382 | 383 | /// Processes table row markup. 384 | /// - Parameter tableRow: The table head markup to process. 385 | /// - Returns: A string containing zero or more HTML elements 386 | /// representing the rows in this table, with each row containing zero or 387 | /// more elements for each column processed. 388 | public mutating func visitTableRow(_ tableRow: Markdown.Table.Row) -> String { 389 | var output = "" 390 | 391 | for child in tableRow.children { 392 | output += "" 393 | output += visit(child) 394 | output += "" 395 | } 396 | 397 | output += "" 398 | return output 399 | } 400 | 401 | /// Processes plain text markup. 402 | /// - Parameter text: The plain text markup to process. 403 | /// - Returns: The same text that was read as input. 404 | public mutating func visitText(_ text: Markdown.Text) -> String { 405 | text.plainText 406 | } 407 | 408 | /// Process thematic break markup. This is written as --- in Markdown. 409 | /// - Parameter thematicBreak: The thematic break markup to process. 410 | /// - Returns: A HTML
      element. 411 | public func visitThematicBreak(_ thematicBreak: Markdown.ThematicBreak) -> String { 412 | "
      " 413 | } 414 | 415 | /// Processes ordered list markup. 416 | /// - Parameter orderedList: The ordered list markup to process. 417 | /// - Returns: A HTML
        element with the correct contents. 418 | public mutating func visitUnorderedList(_ unorderedList: Markdown.UnorderedList) -> String { 419 | var result = "
          " 420 | 421 | for listItem in unorderedList.listItems { 422 | result += visit(listItem) 423 | } 424 | 425 | result += "
        " 426 | return result 427 | } 428 | } 429 | 430 | extension Markup { 431 | /// A small helper that determines whether this markup or any parent is a list. 432 | var isInsideList: Bool { 433 | self is ListItemContainer || parent?.isInsideList == true 434 | } 435 | } 436 | 437 | extension String { 438 | func poorHtmlEncoded() -> String { 439 | replacingOccurrences(of: "&", with: "&") // Ampersand (&) 440 | .replacingOccurrences(of: "<", with: "<") // Less than (<) 441 | .replacingOccurrences(of: ">", with: ">") // Greater than (>) 442 | .replacingOccurrences(of: "\"", with: """) // Double quotes (") 443 | .replacingOccurrences(of: "'", with: "'") // Single quote (') 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /Tests/GenesisTests/GenesisTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Genesis 2 | import Testing 3 | 4 | @Test func example() async throws { 5 | // Write your test here and use APIs like `#expect(...)` to check expected conditions. 6 | } 7 | --------------------------------------------------------------------------------