├── .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 |
102 | \(postsListItems(context: context))
103 |
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 #"
"#
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 |
--------------------------------------------------------------------------------