├── .gitignore ├── Documentation ├── HowTo │ ├── SyntaxHighlighting │ │ ├── using-highlight-js.md │ │ ├── using-pygments.md │ │ └── using-splash.md │ ├── adding-disqus-comments-to-item-pages.md │ ├── conditionally-run-a-step.md │ ├── custom-markdown-metadata-values.md │ ├── nested-items.md │ └── using-a-custom-date-formatter.md └── README.md ├── LICENSE ├── Logo.png ├── Makefile ├── Package.resolved ├── Package.swift ├── README.md ├── Resources └── FoundationTheme │ └── styles.css ├── Sources ├── Publish │ ├── API │ │ ├── AnyItem.swift │ │ ├── Audio.swift │ │ ├── Content.swift │ │ ├── ContentProtocol.swift │ │ ├── DeploymentMethod.swift │ │ ├── Favicon.swift │ │ ├── FeedConfiguration.swift │ │ ├── HTMLFactory.swift │ │ ├── HTMLFileMode.swift │ │ ├── Index.swift │ │ ├── Item.swift │ │ ├── ItemRSSProperties.swift │ │ ├── Location.swift │ │ ├── Mutations.swift │ │ ├── Page.swift │ │ ├── Path.swift │ │ ├── PlotComponents.swift │ │ ├── PlotEnvironmentKeys.swift │ │ ├── PlotModifiers.swift │ │ ├── Plugin.swift │ │ ├── PodcastAuthor.swift │ │ ├── PodcastCompatibleWebsiteItemMetadata.swift │ │ ├── PodcastEpisodeMetadata.swift │ │ ├── PodcastFeedConfiguration.swift │ │ ├── Predicate.swift │ │ ├── PublishedWebsite.swift │ │ ├── PublishingContext.swift │ │ ├── PublishingError.swift │ │ ├── PublishingStep.swift │ │ ├── RSSFeedConfiguration.swift │ │ ├── Section.swift │ │ ├── SectionMap.swift │ │ ├── SortOrder.swift │ │ ├── StringWrapper.swift │ │ ├── Tag.swift │ │ ├── TagDetailsPage.swift │ │ ├── TagHTMLConfiguration.swift │ │ ├── TagListPage.swift │ │ ├── Theme+Foundation.swift │ │ ├── Theme.swift │ │ ├── Video.swift │ │ └── Website.swift │ └── Internal │ │ ├── Array+Appending.swift │ │ ├── CommandLine+Output.swift │ │ ├── ContentError.swift │ │ ├── File+SwiftPackageFolder.swift │ │ ├── FileIOError.swift │ │ ├── Folder+Group.swift │ │ ├── HTMLGenerator.swift │ │ ├── MarkdownContentFactory.swift │ │ ├── MarkdownFileHandler.swift │ │ ├── MarkdownMetadataDecoder.swift │ │ ├── PodcastError.swift │ │ ├── PodcastFeedGenerator.swift │ │ ├── PublishingPipeline.swift │ │ ├── RSSFeedGenerator.swift │ │ ├── ShellOutError+PublishingErrorConvertible.swift │ │ ├── SiteMapGenerator.swift │ │ └── String+Normalized.swift ├── PublishCLI │ └── main.swift └── PublishCLICore │ ├── CLI.swift │ ├── CLIError.swift │ ├── Folder+SwiftPackage.swift │ ├── ProjectGenerator.swift │ ├── ProjectKind.swift │ ├── WebsiteDeployer.swift │ ├── WebsiteGenerator.swift │ └── WebsiteRunner.swift └── Tests └── PublishTests ├── Infrastructure ├── AnyError.swift ├── Assertions.swift ├── Files+Temporary.swift ├── HTMLFactoryMock.swift ├── Item+Stubbable.swift ├── Page+Stubbable.swift ├── PublishTestCase.swift ├── Require.swift ├── String+Unique.swift ├── Stubbable.swift └── WebsiteStub.swift └── Tests ├── CLITests.swift ├── ContentMutationTests.swift ├── DeploymentTests.swift ├── ErrorTests.swift ├── FileIOTests.swift ├── HTMLGenerationTests.swift ├── MarkdownTests.swift ├── PathTests.swift ├── PlotComponentTests.swift ├── PluginTests.swift ├── PodcastFeedGenerationTests.swift ├── PublishingContextTests.swift ├── RSSFeedGenerationTests.swift ├── SiteMapGenerationTests.swift └── WebsiteTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | .swiftpm 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /Documentation/HowTo/SyntaxHighlighting/using-highlight-js.md: -------------------------------------------------------------------------------- 1 | # How to add syntax highlighting using highlight.js 2 | 3 | Highlight.js is a popular tool to use when adding syntax highlighting to code blocks on websites. The plugin `HighlightJSPublishPlugin` is using highlight.js and JavaScriptCore to add the syntax highlighting when the page is generated. So you can still get your webpage javascript free if you would like to. 4 | 5 | Please follow the plugin's [installation guide](https://github.com/alex-ross/HighlightJSPublishPlugin#installation) to install HighlightJSPublishPlugin and to add it to your website's Swift package, and then add it to your publishing pipeline: 6 | ```swift 7 | import HighlightJSPublishPlugin 8 | ... 9 | try MyWebsite().publish(using: [ 10 | .installPlugin(.highlightJS()), 11 | ... 12 | .addMarkdownFiles(), 13 | ... 14 | ]) 15 | ``` 16 | 17 | Please refer to the [usage guide](https://github.com/alex-ross/HighlightJSPublishPlugin#usage) to see how to specify syntax language and more. 18 | 19 | Note that you will need to add CSS for `highlight.js` to actually see the highlighted code. 20 | 21 | For more syntax highlighting solutions, please look at the [how to's index](../../README.md). 22 | -------------------------------------------------------------------------------- /Documentation/HowTo/SyntaxHighlighting/using-pygments.md: -------------------------------------------------------------------------------- 1 | # How to add syntax highlighting with Pygments 2 | [Splash](https://github.com/JohnSundell/Splash) and its [official plugin](https://github.com/JohnSundell/SplashPublishPlugin) are great tools for highlighting Swift syntax when using Publish. 3 | 4 | However, some people write not only in Swift, but also in many other languages. That's when [SwiftPygmentsPublishPlugin](https://github.com/Ze0nC/SwiftPygmentsPublishPlugin) can be really useful. 5 | 6 | Please follow the plugin's [installation guide](https://github.com/Ze0nC/SwiftPygmentsPublishPlugin#installation) to install Pygments and to add it to your website's Swift package, and then add it to your publishing pipeline: 7 | 8 | ```swift 9 | import SwiftPygmentsPublishPlugin 10 | ... 11 | try MyWebsite().publish(using: [ 12 | .installPlugin(.pygments()), 13 | ... 14 | .addMarkdownFiles(), 15 | ... 16 | ]) 17 | ``` 18 | 19 | Please refer to the [usage guide](https://github.com/Ze0nC/SwiftPygmentsPublishPlugin#usage) to see how to specify syntax language and more. 20 | 21 | Note that you will need to add CSS for `Pygments` to actually see the highlighted code. 22 | -------------------------------------------------------------------------------- /Documentation/HowTo/SyntaxHighlighting/using-splash.md: -------------------------------------------------------------------------------- 1 | # How to add Swift syntax highlighting to Markdown code blocks 2 | 3 | If you’re using Publish to write articles about Swift development, then you probably want to highlight the code blocks within those articles according to Swift’s syntax. 4 | 5 | While there are a number of tools that you can use to accomplish this (including several JavaScript-based tools that can be added to any website), Publish is fully compatible with the Swift syntax highlighter [Splash](https://github.com/JohnSundell/Splash), which can be easily added using its [official plugin](https://github.com/JohnSundell/SplashPublishPlugin). 6 | 7 | Start by following the plugin’s [installation instructions](https://github.com/JohnSundell/SplashPublishPlugin#installation) to add it to your website’s Swift package. Then add it to your publishing pipeline (before your Markdown files are processed): 8 | 9 | ```swift 10 | try MyWebsite().publish(using: [ 11 | .installPlugin(.splash(withClassPrefix: "")), 12 | ... 13 | .addMarkdownFiles() 14 | ]) 15 | ``` 16 | 17 | That’ll automatically highlight all code blocks (except the ones marked using `no-highlight`). However, to actually see the syntax highlighting rendered within a web browser, you also need to define a set of CSS styles corresponding to the classes that Splash will assign to each code token. An example CSS file can be [found here](https://github.com/JohnSundell/Splash/blob/master/Examples/sundellsColors.css). -------------------------------------------------------------------------------- /Documentation/HowTo/adding-disqus-comments-to-item-pages.md: -------------------------------------------------------------------------------- 1 | # How to add Disqus comments to item pages 2 | 3 | ## Getting a shortname from Disqus 4 | 5 | Before proceeding, make sure that you've [registered](https://disqus.com/register/) a Disqus [shortname](https://help.disqus.com/customer/portal/articles/286833), as this will be used to reference all of your comments and settings. 6 | 7 | Once you've got your shortname, you can proceed with the next steps. 8 | 9 | ## JavaScript 10 | 11 | Create a new file called `disqus.js` in your `Resources` folder and paste this code within that file: 12 | 13 | ```javascript 14 | (function() { 15 | var t = document, 16 | e = t.createElement("script"); 17 | e.src = "https://REPLACE-WITH-SHORTNAME.disqus.com/embed.js", e.setAttribute("data-timestamp", +new Date), (t.head || t.body).appendChild(e) 18 | })(); 19 | ``` 20 | 21 | Don't forget to replace `REPLACE-WITH-SHORTNAME` with your shortname. 22 | 23 | ## Swift 24 | 25 | In your theme file add the following code to your `makeItemHTML` function. This ensures that comment threads are only shown on item pages: 26 | 27 | ```swift 28 | func makeItemHTML(for item: Item, 29 | context: PublishingContext) throws -> HTML { 30 | ... 31 | .div(.id("disqus_thread")), 32 | .script(.src("/disqus.js")), 33 | .element(named: "noscript", text: "Please enable JavaScript to view the comments") 34 | ... 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /Documentation/HowTo/conditionally-run-a-step.md: -------------------------------------------------------------------------------- 1 | # How to conditionally run a publishing step 2 | 3 | If you have a publishing step that you don’t necessarily want to run every time that your website is generated or deployed, then wrap it in an `if` conditional to only run it in case an expression evaluated to `true`: 4 | 5 | ```swift 6 | func shouldAddPrefixToItems() -> Bool { 7 | ... 8 | } 9 | 10 | try MyWebsite().publish(using: [ 11 | .if(shouldAddPrefixToItems(), .mutateAllItems { item in 12 | item.title = "Prefix: " + item.title 13 | }) 14 | ]) 15 | ``` -------------------------------------------------------------------------------- /Documentation/HowTo/custom-markdown-metadata-values.md: -------------------------------------------------------------------------------- 1 | # How to express custom metadata values using Markdown 2 | 3 | Publish enables each website to define its own site-specific item metadata, through its `Website.ItemMetadata` type. When adding items using Markdown, those values can then be expressed by adding a metadata header at the top of a file (within other tools referred to as *front matter*). 4 | 5 | Let’s say that we’re building an shopping website, and that we’ve defined a custom `productPrice` item metadata value, like this: 6 | 7 | ```swift 8 | struct ShoppingWebsite: Website { 9 | struct ItemMetadata: WebsiteItemMetadata { 10 | var productPrice: Int 11 | } 12 | 13 | ... 14 | } 15 | ``` 16 | 17 | Just by adding it to our `ItemMetadata` type, our new value can now be expressed by using its name within any item’s Markdown metadata header: 18 | 19 | ```markdown 20 | --- 21 | productPrice: 250 22 | --- 23 | 24 | # A fantastic product 25 | 26 | ... 27 | ``` 28 | 29 | The above implementation assumes that *all* items within our website will contain a `productPrice` declaration, since we’ve made it non-optional (which will result in an error in case it’s missing). If that’s not what we want, then we can make it an optional (`Int?`) instead: 30 | 31 | ```swift 32 | struct ItemMetadata: WebsiteItemMetadata { 33 | var productPrice: Int? 34 | } 35 | ``` 36 | 37 | Publish also supports nested metadata values, as long as they can be decoded from raw values — like strings, integers, and doubles. For example, if we wanted to also add a *product category* property to our site-specific item metadata, then we could do that by introducing a new `ProductInfo` type — like this: 38 | 39 | ```swift 40 | // Note that our nested type must also conform to 'WebsiteItemMetadata', 41 | // in order for Publish to be able to decode it from Markdown: 42 | struct ProductInfo: WebsiteItemMetadata { 43 | var price: Int 44 | var category: String 45 | } 46 | ``` 47 | 48 | We’ll then update our `ItemMetadata` type to use our new `ProductInfo` type: 49 | 50 | ```swift 51 | struct ItemMetadata: WebsiteItemMetadata { 52 | var product: ProductInfo? 53 | } 54 | ``` 55 | 56 | In order to express our nested `price` and `category` values, we simply have to specify their full path, and Publish will decode them accordingly: 57 | 58 | ```markdown 59 | --- 60 | product.price: 250 61 | product.category: Electronics 62 | --- 63 | 64 | # A fantastic product 65 | 66 | ... 67 | ``` 68 | 69 | Finally, we can also use arrays within any custom metadata type (again as long as the elements of such arrays can be expressed using raw values). For example, let’s add a `keywords` property to `ProductInfo`: 70 | 71 | ```swift 72 | struct ProductInfo: WebsiteItemMetadata { 73 | var price: Int 74 | var category: String 75 | var keywords: [String] 76 | } 77 | ``` 78 | 79 | Array-based properties are expressed using comma-separate lists (just like how Publish’s built-in `tags` property is used), like this: 80 | 81 | ```markdown 82 | --- 83 | product.price: 250 84 | product.category: Electronics 85 | product.keywords: low-power, efficient, accessory 86 | --- 87 | 88 | # A fantastic product 89 | 90 | ... 91 | ``` 92 | 93 | Publish’s item metadata capabilities are incredibly powerful, since they enable us to express completely custom values in a way that’s fully type-safe. -------------------------------------------------------------------------------- /Documentation/HowTo/nested-items.md: -------------------------------------------------------------------------------- 1 | # How to nest items within folders 2 | 3 | If you want to place items nested within folders, for example according to the month they were published — then simply create any folder structure that you’d like within a given section’s `Content` folder, for example like this: 4 | 5 | ``` 6 | Content 7 | sectionOne 8 | 2019 9 | january 10 | one-item.md 11 | february 12 | another-item.md 13 | and-another-one.md 14 | sectionTwo 15 | 2018 16 | november 17 | an-older-item.md 18 | ``` 19 | 20 | Publish will then output the HTML for the above items with the exact same folder structure, like this: 21 | 22 | ``` 23 | Output 24 | sectionOne 25 | 2019 26 | january 27 | one-item 28 | index.html 29 | february 30 | another-item 31 | index.html 32 | and-another-one 33 | index.html 34 | sectionTwo 35 | 2018 36 | november 37 | an-older-item 38 | index.html 39 | ``` 40 | 41 | If you’d rather specify an item’s path using Markdown metadata, instead of setting up the above kind of folder structure, then you can do that like this: 42 | 43 | ```markdown 44 | --- 45 | path: path/to/my/item 46 | --- 47 | 48 | # My nested item 49 | ``` -------------------------------------------------------------------------------- /Documentation/HowTo/using-a-custom-date-formatter.md: -------------------------------------------------------------------------------- 1 | # How to use a custom date formatter 2 | 3 | If you’d like Publish to use a custom `DateFormatter`, rather than its built-in one (which decodes dates using the `yyyy-MM-dd HH:mm` format), then you can assign a new instance to the current `PublishingContext` within a custom step: 4 | 5 | ```swift 6 | try MyWebsite.publish(using: [ 7 | ... 8 | .step(named: "Use custom DateFormatter") { context in 9 | let formatter = DateFormatter() 10 | ... 11 | context.dateFormatter = formatter 12 | } 13 | ]) 14 | ``` -------------------------------------------------------------------------------- /Documentation/README.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | Welcome to Publish’s documentation portal. While you can find a ton of API-specific documentation within the source code itself, this collection contains more conceptual documentation focused on helping you understand how to use Publish’s various features and capabilities. 4 | 5 | ## “How to”s 6 | 7 | Shorter articles focused on explaining how to get a given task done using Publish: 8 | 9 | - [Adding Disqus comments to posts](HowTo/adding-disqus-comments-to-item-pages.md) 10 | - Adding syntax highlighting to markdown code blocks: 11 | - [Using Splash, a native Swift syntax highlighter for Swift code](HowTo/SyntaxHighlighting/using-splash.md) 12 | - [Using Pygments, a Python tool with support for over 500 languages](HowTo/SyntaxHighlighting/using-pygments.md) 13 | - [Using highlight.js, a JavaScript tool with support for over 180 languages](HowTo/SyntaxHighlighting/using-highlight-js.md) 14 | - [Conditionally running a publishing step](HowTo/conditionally-run-a-step.md) 15 | - [Expressing custom metadata values using Markdown](HowTo/custom-markdown-metadata-values.md) 16 | - [Nesting items within folders](HowTo/nested-items.md) 17 | - [Using a custom `DateFormatter`](HowTo/using-a-custom-date-formatter.md) 18 | 19 | *Contributions adding more “How to” articles, or other kinds of documentation, are more than welcome.* 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 John Sundell 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 | -------------------------------------------------------------------------------- /Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JohnSundell/Publish/58e943047882a5a6d8135ae2711be8ba7fba57c4/Logo.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | install: 2 | swift build -c release 3 | install .build/release/publish-cli /usr/local/bin/publish 4 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Codextended", 6 | "repositoryURL": "https://github.com/johnsundell/codextended.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "8d7c46dfc9c55240870cf5561d6cefa41e3d7105", 10 | "version": "0.3.0" 11 | } 12 | }, 13 | { 14 | "package": "CollectionConcurrencyKit", 15 | "repositoryURL": "https://github.com/johnsundell/collectionConcurrencyKit.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "2e4984dcaed6432f4eff175f6616ba463428cd8a", 19 | "version": "0.1.0" 20 | } 21 | }, 22 | { 23 | "package": "Files", 24 | "repositoryURL": "https://github.com/johnsundell/files.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "d273b5b7025d386feef79ef6bad7de762e106eaf", 28 | "version": "4.2.0" 29 | } 30 | }, 31 | { 32 | "package": "Ink", 33 | "repositoryURL": "https://github.com/johnsundell/ink.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "77c3d8953374a9cf5418ef0bd7108524999de85a", 37 | "version": "0.5.1" 38 | } 39 | }, 40 | { 41 | "package": "Plot", 42 | "repositoryURL": "https://github.com/johnsundell/plot.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "80612b34252188edbef280e5375e2fc5249ac770", 46 | "version": "0.9.0" 47 | } 48 | }, 49 | { 50 | "package": "ShellOut", 51 | "repositoryURL": "https://github.com/johnsundell/shellout.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "e1577acf2b6e90086d01a6d5e2b8efdaae033568", 55 | "version": "2.3.0" 56 | } 57 | }, 58 | { 59 | "package": "Sweep", 60 | "repositoryURL": "https://github.com/johnsundell/sweep.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "801c2878e4c6c5baf32fe132e1f3f3af6f9fd1b0", 64 | "version": "0.4.0" 65 | } 66 | } 67 | ] 68 | }, 69 | "version": 1 70 | } 71 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | 3 | /** 4 | * Publish 5 | * Copyright (c) John Sundell 2019 6 | * MIT license, see LICENSE file for details 7 | */ 8 | 9 | import PackageDescription 10 | 11 | let package = Package( 12 | name: "Publish", 13 | platforms: [.macOS(.v12)], 14 | products: [ 15 | .library(name: "Publish", targets: ["Publish"]), 16 | .executable(name: "publish-cli", targets: ["PublishCLI"]) 17 | ], 18 | dependencies: [ 19 | .package( 20 | name: "Ink", 21 | url: "https://github.com/johnsundell/ink.git", 22 | from: "0.2.0" 23 | ), 24 | .package( 25 | name: "Plot", 26 | url: "https://github.com/johnsundell/plot.git", 27 | from: "0.9.0" 28 | ), 29 | .package( 30 | name: "Files", 31 | url: "https://github.com/johnsundell/files.git", 32 | from: "4.0.0" 33 | ), 34 | .package( 35 | name: "Codextended", 36 | url: "https://github.com/johnsundell/codextended.git", 37 | from: "0.1.0" 38 | ), 39 | .package( 40 | name: "ShellOut", 41 | url: "https://github.com/johnsundell/shellout.git", 42 | from: "2.3.0" 43 | ), 44 | .package( 45 | name: "Sweep", 46 | url: "https://github.com/johnsundell/sweep.git", 47 | from: "0.4.0" 48 | ), 49 | .package( 50 | name: "CollectionConcurrencyKit", 51 | url: "https://github.com/johnsundell/collectionConcurrencyKit.git", 52 | from: "0.1.0" 53 | ) 54 | ], 55 | targets: [ 56 | .target( 57 | name: "Publish", 58 | dependencies: [ 59 | "Ink", "Plot", "Files", "Codextended", 60 | "ShellOut", "Sweep", "CollectionConcurrencyKit" 61 | ] 62 | ), 63 | .executableTarget( 64 | name: "PublishCLI", 65 | dependencies: ["PublishCLICore"] 66 | ), 67 | .target( 68 | name: "PublishCLICore", 69 | dependencies: ["Publish"] 70 | ), 71 | .testTarget( 72 | name: "PublishTests", 73 | dependencies: ["Publish", "PublishCLICore"] 74 | ) 75 | ] 76 | ) 77 | -------------------------------------------------------------------------------- /Resources/FoundationTheme/styles.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish Foundation theme 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | * { 8 | margin: 0; 9 | padding: 0; 10 | box-sizing: border-box; 11 | } 12 | 13 | body { 14 | background: #fff; 15 | color: #000; 16 | font-family: Helvetica, Arial; 17 | text-align: center; 18 | } 19 | 20 | .wrapper { 21 | max-width: 900px; 22 | margin-left: auto; 23 | margin-right: auto; 24 | padding: 40px; 25 | text-align: left; 26 | } 27 | 28 | header { 29 | background-color: #eee; 30 | } 31 | 32 | header .wrapper { 33 | padding-top: 30px; 34 | padding-bottom: 30px; 35 | text-align: center; 36 | } 37 | 38 | header a { 39 | text-decoration: none; 40 | } 41 | 42 | header .site-name { 43 | font-size: 1.5em; 44 | color: #000; 45 | font-weight: bold; 46 | } 47 | 48 | nav { 49 | margin-top: 20px; 50 | } 51 | 52 | nav li { 53 | display: inline-block; 54 | margin: 0 7px; 55 | line-height: 1.5em; 56 | } 57 | 58 | nav li a.selected { 59 | text-decoration: underline; 60 | } 61 | 62 | h1 { 63 | margin-bottom: 20px; 64 | font-size: 2em; 65 | } 66 | 67 | h2 { 68 | margin: 20px 0; 69 | } 70 | 71 | p { 72 | margin-bottom: 10px; 73 | } 74 | 75 | a { 76 | color: inherit; 77 | } 78 | 79 | .description { 80 | margin-bottom: 40px; 81 | } 82 | 83 | .item-list > li { 84 | display: block; 85 | padding: 20px; 86 | border-radius: 20px; 87 | background-color: #eee; 88 | margin-bottom: 20px; 89 | } 90 | 91 | .item-list > li:last-child { 92 | margin-bottom: 0; 93 | } 94 | 95 | .item-list h1 { 96 | margin-bottom: 15px; 97 | font-size: 1.3em; 98 | } 99 | 100 | .item-list p { 101 | margin-bottom: 0; 102 | } 103 | 104 | .tag-list { 105 | margin-bottom: 10px; 106 | } 107 | 108 | .tag-list li, 109 | .tag { 110 | display: inline-block; 111 | background-color: #000; 112 | color: #ddd; 113 | padding: 4px 6px; 114 | border-radius: 5px; 115 | margin-right: 5px; 116 | margin-bottom: 5px; 117 | } 118 | 119 | .tag-list a, 120 | .tag a { 121 | text-decoration: none; 122 | } 123 | 124 | .item-page .tag-list { 125 | display: inline-block; 126 | } 127 | 128 | .content { 129 | margin-bottom: 40px; 130 | } 131 | 132 | .browse-all { 133 | display: block; 134 | margin-bottom: 30px; 135 | } 136 | 137 | .all-tags li { 138 | font-size: 1.4em; 139 | margin-right: 10px; 140 | margin-bottom: 10px; 141 | padding: 6px 10px; 142 | } 143 | 144 | footer { 145 | color: #8a8a8a; 146 | } 147 | 148 | @media (prefers-color-scheme: dark) { 149 | body { 150 | background-color: #222; 151 | } 152 | 153 | body, 154 | header .site-name { 155 | color: #ddd; 156 | } 157 | 158 | .item-list > li { 159 | background-color: #333; 160 | } 161 | 162 | header { 163 | background-color: #000; 164 | } 165 | } 166 | 167 | @media(max-width: 600px) { 168 | .wrapper { 169 | padding: 40px 20px; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Sources/Publish/API/AnyItem.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type-erased version of a website's item, which can be useful 10 | /// when implementing general-purpose themes or utilities. It 11 | /// doesn't contain site-specific information, such as the item's 12 | /// metadata or section ID. 13 | public protocol AnyItem: Location { 14 | /// The item's tags. Items tagged with the same tag can be 15 | /// queried using either `Section` or `PublishingContext`. 16 | var tags: [Tag] { get } 17 | /// Properties that can be used to customize how an item is 18 | /// presented within an RSS feed. See `ItemRSSProperties`. 19 | var rssProperties: ItemRSSProperties { get } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Publish/API/Audio.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Plot 9 | import Codextended 10 | 11 | /// A representation of a location's audio data. Can be used to 12 | /// implement podcast feeds, or inline audio players using the 13 | /// `audioPlayer` Plot component. 14 | public struct Audio: Hashable { 15 | /// The URL of the audio. Should be an absolute URL. 16 | public var url: URL 17 | /// The format of the audio. See `HTMLAudioFormat`. 18 | public var format: HTMLAudioFormat 19 | /// The duration of the audio. Required for podcasts. 20 | public var duration: Duration? 21 | /// The audio's file size (in bytes). Required for podcasts. 22 | public var byteSize: Int? 23 | 24 | /// Initialize a new instance of this type. 25 | /// - parameter url: The URL of the audio. 26 | /// - parameter format: The format of the audio. 27 | /// - parameter duration: The duration of the audio. 28 | /// - parameter byteSize: The audio's file size (in bytes). 29 | public init(url: URL, 30 | format: HTMLAudioFormat = .mp3, 31 | duration: Duration? = nil, 32 | byteSize: Int? = nil) { 33 | self.url = url 34 | self.format = format 35 | self.duration = duration 36 | self.byteSize = byteSize 37 | } 38 | } 39 | 40 | public extension Audio { 41 | /// A representation of an audio file's duration. 42 | struct Duration: Hashable { 43 | /// The duration's number of hours. 44 | public var hours: Int 45 | /// The duration's number of minutes. 46 | public var minutes: Int 47 | /// The duration's number of seconds. 48 | public var seconds: Int 49 | 50 | /// Initialize a new instance of this type. 51 | /// - Parameter hours: The duration's number of hours. 52 | /// - Parameter minutes: The duration's number of minutes. 53 | /// - Parameter seconds: The duration's number of seconds. 54 | public init(hours: Int = 0, minutes: Int = 0, seconds: Int = 0) { 55 | self.hours = hours 56 | self.minutes = minutes 57 | self.seconds = seconds 58 | } 59 | } 60 | } 61 | 62 | extension Audio: Decodable { 63 | public init(from decoder: Decoder) throws { 64 | url = try decoder.decode("url") 65 | format = try decoder.decodeIfPresent("format") ?? .mp3 66 | duration = try decoder.decodeIfPresent("duration") 67 | byteSize = try decoder.decodeIfPresent("size") 68 | } 69 | } 70 | 71 | extension Audio.Duration: Decodable { 72 | public init(from decoder: Decoder) throws { 73 | self.init() 74 | 75 | let container = try decoder.singleValueContainer() 76 | let string = try container.decode(String.self) 77 | let components = string.split(separator: ":") 78 | 79 | guard (1...3).contains(components.count) else { 80 | throw DecodingError.dataCorruptedError( 81 | in: container, 82 | debugDescription: """ 83 | Audio duration strings should be formatted as either HH:mm:ss or mm:ss 84 | """ 85 | ) 86 | } 87 | 88 | let keyPaths: [WritableKeyPath] = [\.seconds, \.minutes, \.hours] 89 | 90 | for (keyPath, string) in zip(keyPaths, components.reversed()) { 91 | guard let value = Int(string) else { 92 | throw DecodingError.dataCorruptedError( 93 | in: container, 94 | debugDescription: """ 95 | Invalid audio duration component '\(string)' 96 | """ 97 | ) 98 | } 99 | 100 | self[keyPath: keyPath] = value 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sources/Publish/API/Content.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Plot 9 | 10 | /// Type representing a location's main content. 11 | public struct Content: Hashable, ContentProtocol { 12 | public var title: String 13 | public var description: String 14 | public var body: Body 15 | public var date: Date 16 | public var lastModified: Date 17 | public var imagePath: Path? 18 | public var audio: Audio? 19 | public var video: Video? 20 | 21 | /// Initialize a new instance of this type 22 | /// - parameter title: The location's title. 23 | /// - parameter description: A description of the location. 24 | /// - parameter body: The main body of the location's content. 25 | /// - parameter date: The location's main publishing date. 26 | /// - parameter lastModified: The last modification date. 27 | /// - parameter imagePath: A path to any image for the location. 28 | /// - parameter audio: Any audio data associated with this content. 29 | /// - parameter video: Any video data associated with this content. 30 | public init(title: String = "", 31 | description: String = "", 32 | body: Body = Body(html: ""), 33 | date: Date = Date(), 34 | lastModified: Date = Date(), 35 | imagePath: Path? = nil, 36 | audio: Audio? = nil, 37 | video: Video? = nil) { 38 | self.title = title 39 | self.description = description 40 | self.body = body 41 | self.date = date 42 | self.lastModified = lastModified 43 | self.imagePath = imagePath 44 | self.audio = audio 45 | self.video = video 46 | } 47 | } 48 | 49 | public extension Content { 50 | /// Type that represents the main renderable body of a piece of content. 51 | struct Body: Hashable { 52 | /// The content's renderable HTML. 53 | public var html: String 54 | /// A node that can be used to embed the content in a Plot hierarchy. 55 | public var node: Node { .raw(html) } 56 | /// Whether this value doesn't contain any content. 57 | public var isEmpty: Bool { html.isEmpty } 58 | 59 | /// Initialize an instance with a ready-made HTML string. 60 | /// - parameter html: The content HTML that the instance should cointain. 61 | public init(html: String) { 62 | self.html = html 63 | } 64 | 65 | /// Initialize an instance with a Plot `Node`. 66 | /// - parameter node: The node to render. See `Node` for more information. 67 | /// - parameter indentation: Any indentation to apply when rendering the node. 68 | public init(node: Node, 69 | indentation: Indentation.Kind? = nil) { 70 | html = node.render(indentedBy: indentation) 71 | } 72 | 73 | /// Initialize an instance using Plot's `Component` API. 74 | /// - parameter indentation: Any indentation to apply when rendering the components. 75 | /// - parameter components: The components that should make up this instance's content. 76 | public init(indentation: Indentation.Kind? = nil, 77 | @ComponentBuilder components: () -> Component) { 78 | self.init(node: .component(components()), 79 | indentation: indentation) 80 | } 81 | } 82 | } 83 | 84 | extension Content.Body: ExpressibleByStringInterpolation { 85 | public init(stringLiteral value: String) { 86 | self.init(html: value) 87 | } 88 | } 89 | 90 | extension Content.Body: Component { 91 | public var body: Component { node } 92 | } 93 | -------------------------------------------------------------------------------- /Sources/Publish/API/ContentProtocol.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Protocol adopted by types that represent the content for a location. 10 | public protocol ContentProtocol { 11 | /// The location's title. When parsing a location from Markdown, 12 | /// the top-level H1 heading will be used as the location's title, 13 | /// which can also be overridden using the `title` metadata key. 14 | var title: String { get set } 15 | /// A description of the location. When parsing a location from 16 | /// Markdown, a description may be defined using the `description` 17 | /// metadata key. 18 | var description: String { get set } 19 | /// The main body of the location's content. Can either be defined 20 | /// using raw HTML, Markdown, or by using a Plot `Node` hierarchy. 21 | var body: Content.Body { get set } 22 | /// The main publishing date of the location. Typically used to sort 23 | /// lists of locations or when generating RSS feeds, and can also be 24 | /// formatted and displayed to the user. When parsing a location from 25 | /// Markdown, this date can be defined using the `date` metadata key, 26 | /// and otherwise defaults to the last modification date of the file. 27 | var date: Date { get set } 28 | /// The date when the location was last modified. When parsing a location 29 | /// from Markdown, this date will default to the last modification 30 | /// date of the file. 31 | var lastModified: Date { get set } 32 | /// Any path to an image that should be associated with the location. 33 | /// Can be defined using the Markdown `image` metadata key. When using 34 | /// Publish's built-in way to define HTML head elements, this property 35 | /// is used for the location's social media image. 36 | var imagePath: Path? { get set } 37 | /// Any audio data that should be associated with the location. Can be 38 | /// used to implement Podcast feeds, or to display inline audio players 39 | /// within a website, using the `audioPlayer` Plot component. Required 40 | /// when using the `generatePodcastFeed` step. See `Audio` for more info. 41 | var audio: Audio? { get set } 42 | /// Any video data that should be associated with the location, which 43 | /// can be used to display inline video players using the `videoPlayer` 44 | /// Plot component. See `Video` for more info. 45 | var video: Video? { get set } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/Publish/API/DeploymentMethod.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Files 9 | import ShellOut 10 | 11 | /// Type used to implement deployment functionality for a website. 12 | /// When implementing reusable deployment methods that are vended as 13 | /// frameworks or APIs, it's recommended to create them using static 14 | /// factory methods, just like how the built-in `git` and `gitHub` 15 | /// deployment methods are implemented. 16 | public struct DeploymentMethod { 17 | /// Closure type used to implement the deployment method's main 18 | /// body. It's passed the `PublishingContext` of the current 19 | /// session, and can use that to create a dedicated deployment folder. 20 | public typealias Body = (PublishingContext) throws -> Void 21 | 22 | /// The human-readable name of the deployment method. 23 | public var name: String 24 | /// The deployment method's main body. See `Body` for more info. 25 | public var body: Body 26 | 27 | /// Initialize a new deployment method. 28 | /// - parameter name: The method's human-readable name. 29 | /// - parameter body: The method's main body. 30 | public init(name: String, body: @escaping Body) { 31 | self.name = name 32 | self.body = body 33 | } 34 | } 35 | 36 | public extension DeploymentMethod { 37 | /// Deploy a website to a given remote using Git. 38 | /// - parameter remote: The full address of the remote to deploy to. 39 | /// - parameter branch: The branch to push to and pull from (default is master). 40 | static func git(_ remote: String, branch: String = "master") -> Self { 41 | DeploymentMethod(name: "Git (\(remote))") { context in 42 | let folder = try context.createDeploymentFolder(withPrefix: "Git") { folder in 43 | if !folder.containsSubfolder(named: ".git") { 44 | try shellOut(to: .gitInit(), at: folder.path) 45 | 46 | try shellOut( 47 | to: "git remote add origin \(remote)", 48 | at: folder.path 49 | ) 50 | } 51 | 52 | try shellOut( 53 | to: "git remote set-url origin \(remote)", 54 | at: folder.path 55 | ) 56 | 57 | _ = try? shellOut( 58 | to: .gitPull(remote: "origin", branch: branch), 59 | at: folder.path 60 | ) 61 | 62 | try shellOut( 63 | to: "git checkout \(branch) || git checkout -b \(branch)", 64 | at: folder.path 65 | ) 66 | 67 | try folder.empty() 68 | } 69 | 70 | let dateFormatter = DateFormatter() 71 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" 72 | let dateString = dateFormatter.string(from: Date()) 73 | 74 | do { 75 | try shellOut( 76 | to: """ 77 | git add . && git commit -a -m \"Publish deploy \(dateString)\" --allow-empty 78 | """, 79 | at: folder.path 80 | ) 81 | 82 | try shellOut( 83 | to: .gitPush(remote: "origin", branch: branch), 84 | at: folder.path 85 | ) 86 | } catch let error as ShellOutError { 87 | throw PublishingError(infoMessage: error.message) 88 | } catch { 89 | throw error 90 | } 91 | } 92 | } 93 | 94 | /// Deploy a website to a given GitHub repository. 95 | /// - parameter repository: The full name of the repository (including its username). 96 | /// - parameter branch: The branch to push to and pull from (default is master). 97 | /// - parameter useSSH: Whether an SSH connection should be used (preferred). 98 | static func gitHub(_ repository: String, branch: String = "master", useSSH: Bool = true) -> Self { 99 | let prefix = useSSH ? "git@github.com:" : "https://github.com/" 100 | return git("\(prefix)\(repository).git", branch: branch) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/Publish/API/Favicon.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// A representation of a website's "favicon" (a small icon typically 10 | /// displayed along the website's title in various browser UIs). 11 | public struct Favicon { 12 | /// The favicon's absolute path. 13 | public var path: Path 14 | /// The MIME type of the image. 15 | public var type: String 16 | 17 | /// Initialize a new instance of this type 18 | /// - Parameter path: The favicon's absolute path (default: "images/favicon.png"). 19 | /// - Parameter type: The MIME type of the image (default: "image/png"). 20 | public init(path: Path = .defaultForFavicon, type: String = "image/png") { 21 | self.path = path 22 | self.type = type 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Publish/API/FeedConfiguration.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2020 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Plot 9 | 10 | /// Protocol that acts as a shared API for configuring various feed 11 | /// generation steps, such as `generateRSSFeed` and `generatePodcastFeed`. 12 | public protocol FeedConfiguration: Codable, Equatable { 13 | /// The path that the feed should be generated at. 14 | var targetPath: Path { get } 15 | /// The feed's TTL (or "Time to live") time interval. 16 | var ttlInterval: TimeInterval { get } 17 | /// The maximum number of items that the feed should contain. 18 | var maximumItemCount: Int { get } 19 | /// How the feed should be indented. 20 | var indentation: Indentation.Kind? { get } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Publish/API/HTMLFactory.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Plot 8 | 9 | /// Protocol used to implement a website theme's underlying factory, 10 | /// that creates HTML for a site's various locations using the Plot DSL. 11 | public protocol HTMLFactory { 12 | /// The website that the factory is for. Generic constraints may be 13 | /// applied to this type to require that a website fulfills certain 14 | /// requirements in order to use this factory. 15 | associatedtype Site: Website 16 | 17 | /// Create the HTML to use for the website's main index page. 18 | /// - parameter index: The index page to generate HTML for. 19 | /// - parameter context: The current publishing context. 20 | func makeIndexHTML(for index: Index, 21 | context: PublishingContext) throws -> HTML 22 | 23 | /// Create the HTML to use for the index page of a section. 24 | /// - parameter section: The section to generate HTML for. 25 | /// - parameter context: The current publishing context. 26 | func makeSectionHTML(for section: Section, 27 | context: PublishingContext) throws -> HTML 28 | 29 | /// Create the HTML to use for an item. 30 | /// - parameter item: The item to generate HTML for. 31 | /// - parameter context: The current publishing context. 32 | func makeItemHTML(for item: Item, 33 | context: PublishingContext) throws -> HTML 34 | 35 | /// Create the HTML to use for a page. 36 | /// - parameter page: The page to generate HTML for. 37 | /// - parameter context: The current publishing context. 38 | func makePageHTML(for page: Page, 39 | context: PublishingContext) throws -> HTML 40 | 41 | /// Create the HTML to use for the website's list of tags, if supported. 42 | /// Return `nil` if the theme that this factory is for doesn't support tags. 43 | /// - parameter page: The tag list page to generate HTML for. 44 | /// - parameter context: The current publishing context. 45 | func makeTagListHTML(for page: TagListPage, 46 | context: PublishingContext) throws -> HTML? 47 | 48 | /// Create the HTML to use for a tag details page, used to represent a single 49 | /// tag. Return `nil` if the theme that this factory is for doesn't support tags. 50 | /// - parameter page: The tag details page to generate HTML for. 51 | /// - parameter context: The current publishing context. 52 | func makeTagDetailsHTML(for page: TagDetailsPage, 53 | context: PublishingContext) throws -> HTML? 54 | } 55 | -------------------------------------------------------------------------------- /Sources/Publish/API/HTMLFileMode.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Enum describing various ways that HTML files may be generated. 10 | public enum HTMLFileMode { 11 | /// Stand-alone HTML files should be generated, so that `section/item` 12 | /// becomes `section/item.html`. 13 | case standAloneFiles 14 | /// HTML index files wrapped in folders should be generated, so that 15 | /// `section/item` becomes `section/item/index.html`. 16 | case foldersAndIndexFiles 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Publish/API/Index.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// A representation of a website's main index page 10 | public struct Index: Location { 11 | public var path: Path { "" } 12 | public var content = Content() 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Publish/API/Item.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// An item represents a website page that is contained within a `Section`, 10 | /// and is typically used to implement lists of content, such as a blogs or 11 | /// article lists, podcasts, and so on. To implement free-form pages, use 12 | /// the `Page` type. Items can either be added programmatically, or through 13 | /// Markdown files placed in their corresponding section's folder. 14 | public struct Item: AnyItem, Hashable { 15 | /// The ID of the section that the item belongs to, as defined by the 16 | /// `Website` that this item is for. 17 | public internal(set) var sectionID: Site.SectionID 18 | /// The item's site-specific metadata, as defined by the `Website` that 19 | /// this item is for. 20 | public var metadata: Site.ItemMetadata 21 | public var tags: [Tag] 22 | public var path: Path { makeAbsolutePath() } 23 | public var content: Content 24 | public var rssProperties: ItemRSSProperties 25 | 26 | internal let relativePath: Path 27 | 28 | /// Initialize a new item programmatically. You can also create items from 29 | /// Markdown using the `addMarkdownFiles` step. 30 | /// - parameter path: The path of the item within its section. 31 | /// - parameter sectionID: The ID of the section that the item belongs to. 32 | /// - parameter metadata: The item's site-specific metadata. 33 | /// - parameter tags: The item's tags. 34 | /// - parameter content: The main content of the item. 35 | /// - parameter rssProperties: Properties customizing the item's RSS representation. 36 | public init(path: Path, 37 | sectionID: Site.SectionID, 38 | metadata: Site.ItemMetadata, 39 | tags: [Tag] = [], 40 | content: Content = Content(), 41 | rssProperties: ItemRSSProperties = .init()) { 42 | self.relativePath = path 43 | self.sectionID = sectionID 44 | self.metadata = metadata 45 | self.tags = tags 46 | self.content = content 47 | self.rssProperties = rssProperties 48 | } 49 | } 50 | 51 | internal extension Item { 52 | var rssTitle: String { 53 | let prefix = rssProperties.titlePrefix ?? "" 54 | let suffix = rssProperties.titleSuffix ?? "" 55 | return prefix + title + suffix 56 | } 57 | } 58 | 59 | private extension Item { 60 | func makeAbsolutePath() -> Path { 61 | "\(sectionID.rawValue)/\(relativePath)" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Publish/API/ItemRSSProperties.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Properties that can be used to customize an item's RSS representation. 10 | public struct ItemRSSProperties: Codable, Hashable { 11 | /// Any specific GUID that should be added for the item. When `nil`, 12 | /// the item's URL will be used and the `isPermaLink` attribute will 13 | /// be set to `true`, unless an explicit `link` was specified. If this 14 | /// property is not `nil`, then a non-permalink GUID will be assumed. 15 | public var guid: String? 16 | /// Any prefix that should be added to the item's title within an RSS feed. 17 | public var titlePrefix: String? 18 | /// Any suffix that should be added to the item's title within an RSS feed. 19 | public var titleSuffix: String? 20 | /// Any prefix that should be added to the item's body HTML within an RSS feed. 21 | public var bodyPrefix: String? 22 | /// Any suffix that should be added to the item's body HTML within an RSS feed. 23 | public var bodySuffix: String? 24 | /// Any specific URL that the item should link to when included in an RSS 25 | /// feed. By default, the item's location on its website will be used. Note that 26 | /// this link won't be automatically used as the item's GUID, however, setting 27 | /// this property to a non-`nil` value will set the GUID's `isPermaLink` attribute 28 | /// to `false`. 29 | public var link: URL? = nil 30 | 31 | /// Initialize an instance of this type 32 | /// - parameter guid: Any specific GUID that should be added for the item. 33 | /// - parameter titlePrefix: Any prefix that should be added to the item's title. 34 | /// - parameter titleSuffix: Any suffix that should be added to the item's title. 35 | /// - parameter bodyPrefix: Any prefix that should be added to the item's body HTML. 36 | /// - parameter bodySuffix: Any suffix that should be added to the item's body HTML. 37 | /// - parameter link: Any specific URL that the item should link to, other than its location. 38 | public init(guid: String? = nil, 39 | titlePrefix: String? = nil, 40 | titleSuffix: String? = nil, 41 | bodyPrefix: String? = nil, 42 | bodySuffix: String? = nil, 43 | link: URL? = nil) { 44 | self.guid = guid 45 | self.titlePrefix = titlePrefix 46 | self.titleSuffix = titleSuffix 47 | self.bodyPrefix = bodyPrefix 48 | self.bodySuffix = bodySuffix 49 | self.link = link 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Publish/API/Location.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Protocol adopted by types that can act as a location 10 | /// that a user can navigate to within a web browser. 11 | public protocol Location: ContentProtocol { 12 | /// The absolute path of the location within the website, 13 | /// excluding its base URL. For example, an item "article" 14 | /// contained within a section "mySection" will have the 15 | /// path "mySection/article". You can resolve the absolute 16 | /// URL for a location and/or path using your `Website`. 17 | var path: Path { get } 18 | /// The location's main content. You can also access this 19 | /// type's nested properties as top-level properties on the 20 | /// location itself, so `title`, rather than `content.title`. 21 | var content: Content { get set } 22 | } 23 | 24 | public extension Location { 25 | var title: String { 26 | get { content.title } 27 | set { content.title = newValue } 28 | } 29 | 30 | 31 | var description: String { 32 | get { content.description } 33 | set { content.description = newValue } 34 | } 35 | 36 | var body: Content.Body { 37 | get { content.body } 38 | set { content.body = newValue } 39 | } 40 | 41 | var date: Date { 42 | get { content.date } 43 | set { content.date = newValue } 44 | } 45 | 46 | var lastModified: Date { 47 | get { content.lastModified } 48 | set { content.lastModified = newValue } 49 | } 50 | 51 | var imagePath: Path? { 52 | get { content.imagePath } 53 | set { content.imagePath = newValue } 54 | } 55 | 56 | var audio: Audio? { 57 | get { content.audio } 58 | set { content.audio = newValue } 59 | } 60 | 61 | var video: Video? { 62 | get { content.video } 63 | set { content.video = newValue } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Publish/API/Mutations.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | /// Closure type used to implement content mutations. 8 | public typealias Mutations = (inout T) throws -> Void 9 | -------------------------------------------------------------------------------- /Sources/Publish/API/Page.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type that represents a free-form page within a website. Pages can have 10 | /// any path or structure, and can contain any content. To implement collections 11 | /// or lists of pages, that should be organized within sections, use `Section` 12 | /// and `Item` instead. Pages can either be added programmatically, or through 13 | /// Markdown files placed within the root of the website's content folder. 14 | public struct Page: Location, Equatable { 15 | public var path: Path 16 | public var content: Content 17 | 18 | /// Initialize a new page programmatically. You can also create pages from 19 | /// Markdown using the `addMarkdownFiles` step. 20 | /// - Parameter path: The absolute path of the page. 21 | /// - Parameter content: The page's content. 22 | public init(path: Path, content: Content) { 23 | self.path = path 24 | self.content = content 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Publish/API/Path.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type used to express a path within a website, either to a 10 | /// location or to a resource, such as a file or image. 11 | public struct Path: StringWrapper { 12 | public var string: String 13 | 14 | public init(_ string: String) { 15 | self.string = string 16 | } 17 | } 18 | 19 | public extension Path { 20 | /// The default path used when generating RSS feeds. 21 | static var defaultForRSSFeed: Path { "feed.rss" } 22 | /// The default path used when generating HTML for tags and tag lists. 23 | static var defaultForTagHTML: Path { "tags" } 24 | /// The default path used for website favicon. 25 | static var defaultForFavicon: Path { "images/favicon.png" } 26 | 27 | /// Convert this path into an absolute string, which can be used to 28 | /// refer to locations and resources based on the root of a website. 29 | var absoluteString: String { 30 | guard string.first != "/" else { return string } 31 | guard !string.hasPrefix("http://") else { return string } 32 | guard !string.hasPrefix("https://") else { return string } 33 | return "/" + string 34 | } 35 | 36 | /// Append a component to this path, such as a folder or file name. 37 | /// - parameter component: The component to add. 38 | func appendingComponent(_ component: String) -> Path { 39 | guard !string.isEmpty else { 40 | return Path(component) 41 | } 42 | 43 | let component = component.drop(while: { $0 == "/" }) 44 | let separator = (string.last == "/" ? "" : "/") 45 | return "\(string)\(separator)\(component)" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Publish/API/PlotEnvironmentKeys.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2021 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Plot 8 | import Ink 9 | 10 | public extension EnvironmentKey where Value == MarkdownParser { 11 | /// Environment key that can be used to pass what `MarkdownParser` that 12 | /// should be used when rendering `Markdown` components. 13 | static var markdownParser: Self { .init(defaultValue: .init()) } 14 | } 15 | -------------------------------------------------------------------------------- /Sources/Publish/API/PlotModifiers.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2021 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Ink 8 | import Plot 9 | 10 | public extension Component { 11 | /// Assign what `MarkdownParser` to use when rendering `Markdown` components 12 | /// within this component hierarchy. This value is placed in the environment, 13 | /// and is thus inherited by all child components. Note that this modifier 14 | /// does not affect nodes rendered using the `.markdown` API. 15 | /// - parameter parser: The parser to assign. 16 | func markdownParser(_ parser: MarkdownParser) -> Component { 17 | environmentValue(parser, key: .markdownParser) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Publish/API/Plugin.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type used to implement Publish plugins, that can be used to customize 10 | /// the publishing process in any way. 11 | public struct Plugin { 12 | /// Closure type used to install a plugin within the current context. 13 | public typealias Installer = PublishingStep.Closure 14 | 15 | /// The human-readable name of the plugin. 16 | public var name: String 17 | /// The closure used to install the plugin within the current context. 18 | public var installer: Installer 19 | 20 | /// Initialize a new plugin instance. 21 | /// - Parameter name: The human-readable name of the plugin. 22 | /// - Parameter installer: The closure used to install the plugin. 23 | public init(name: String, installer: @escaping Installer) { 24 | self.name = name 25 | self.installer = installer 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/Publish/API/PodcastAuthor.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type used to describe the author of a podcast. 10 | public struct PodcastAuthor: Codable, Equatable { 11 | /// The author's full name. 12 | public var name: String 13 | /// The author's email address. 14 | public var emailAddress: String 15 | 16 | /// Initialize a new instance of this type 17 | /// - Parameter name: The author's full name. 18 | /// - Parameter emailAddress: The author's email address. 19 | public init(name: String, emailAddress: String) { 20 | self.name = name 21 | self.emailAddress = emailAddress 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sources/Publish/API/PodcastCompatibleWebsiteItemMetadata.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Protocol adopted by `Website.ItemMetadata` implementations that 10 | /// are podcast-compatible. Conforming to this protocol is a requirement 11 | /// in order to use the `generatePodcastFeed` step. 12 | public protocol PodcastCompatibleWebsiteItemMetadata: WebsiteItemMetadata { 13 | /// The item's podcast episode-specific metadata. 14 | var podcast: PodcastEpisodeMetadata? { get } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Publish/API/PodcastEpisodeMetadata.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Codextended 8 | 9 | /// Type used to describe metadata for a podcast episode. 10 | public struct PodcastEpisodeMetadata: Hashable { 11 | /// The episode's number. 12 | public var episodeNumber: Int? 13 | /// The number of the episode's season. 14 | public var seasonNumber: Int? 15 | /// Whether the episode contains explicit content. 16 | public var isExplicit: Bool 17 | 18 | /// Initialize a new instance of this type. 19 | /// - Parameter episodeNumber: The episode's number. 20 | /// - Parameter seasonNumber: The number of the episode's season. 21 | /// - Parameter isExplicit: Whether the episode contains explicit content. 22 | public init(episodeNumber: Int? = nil, 23 | seasonNumber: Int? = nil, 24 | isExplicit: Bool = false) { 25 | self.episodeNumber = episodeNumber 26 | self.seasonNumber = seasonNumber 27 | self.isExplicit = isExplicit 28 | } 29 | } 30 | 31 | extension PodcastEpisodeMetadata: Decodable { 32 | public init(from decoder: Decoder) throws { 33 | episodeNumber = try decoder.decodeIfPresent("episode") 34 | seasonNumber = try decoder.decodeIfPresent("season") 35 | isExplicit = try decoder.decodeIfPresent("explicit") ?? false 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Publish/API/PodcastFeedConfiguration.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Plot 9 | 10 | /// Configuration type used to customize how a podcast feed is generated when 11 | /// using the `generatePodcastFeed` step. To use a default implementation, 12 | /// use `PodcastFeedConfiguration.default`. 13 | public struct PodcastFeedConfiguration: FeedConfiguration { 14 | public var targetPath: Path 15 | public var ttlInterval: TimeInterval 16 | public var maximumItemCount: Int 17 | public var indentation: Indentation.Kind? 18 | /// The type of the podcast. See `PodcastType`. 19 | public var type: PodcastType 20 | /// A URL that points to the podcast's main image. 21 | public var imageURL: URL 22 | /// The copyright text to add to the podcast feed. 23 | public var copyrightText: String 24 | /// The podcast's author. See `PodcastAuthor`. 25 | public var author: PodcastAuthor 26 | /// A longer description of the podcast. 27 | public var description: String 28 | /// A shorter description, or subtitle, for the podcast. 29 | public var subtitle: String 30 | /// Whether the podcast contains explicit content. 31 | public var isExplicit: Bool 32 | /// The podcast's main top-level category. 33 | public var category: String 34 | /// The podcast's subcategory. 35 | public var subcategory: String? 36 | /// Any new feed URL to instruct Apple Podcasts to use going forward. 37 | public var newFeedURL: URL? 38 | 39 | /// Initialize a new configuration instance. 40 | /// - Parameter targetPath: The path that the feed should be generated at. 41 | /// - Parameter ttlInterval: The feed's TTL time interval. 42 | /// - Parameter maximumItemCount: The maximum number of items that the 43 | /// feed should contain. 44 | /// - Parameter type: The type of the podcast. 45 | /// - Parameter imageURL: A URL that points to the podcast's main image. 46 | /// - Parameter copyrightText: The copyright text to add to the podcast feed. 47 | /// - Parameter author: The podcast's author. 48 | /// - Parameter description: A longer description of the podcast. 49 | /// - Parameter subtitle: A shorter description, or subtitle, for the podcast. 50 | /// - Parameter isExplicit: Whether the podcast contains explicit content. 51 | /// - Parameter category: The podcast's main top-level category. 52 | /// - Parameter subcategory: The podcast's subcategory. 53 | /// - Parameter newFeedURL: Any new feed URL for the podcast. 54 | /// - Parameter indentation: How the feed should be indented. 55 | public init( 56 | targetPath: Path, 57 | ttlInterval: TimeInterval = 250, 58 | maximumItemCount: Int = .max, 59 | type: PodcastType = .episodic, 60 | imageURL: URL, 61 | copyrightText: String, 62 | author: PodcastAuthor, 63 | description: String, 64 | subtitle: String, 65 | isExplicit: Bool = false, 66 | category: String, 67 | subcategory: String? = nil, 68 | newFeedURL: URL? = nil, 69 | indentation: Indentation.Kind? = nil 70 | ) { 71 | self.targetPath = targetPath 72 | self.ttlInterval = ttlInterval 73 | self.maximumItemCount = maximumItemCount 74 | self.indentation = indentation 75 | self.type = type 76 | self.imageURL = imageURL 77 | self.copyrightText = copyrightText 78 | self.author = author 79 | self.description = description 80 | self.subtitle = subtitle 81 | self.category = category 82 | self.subcategory = subcategory 83 | self.isExplicit = isExplicit 84 | self.newFeedURL = newFeedURL 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sources/Publish/API/Predicate.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type used to implement predicates that can be used to filter and 10 | /// conditionally select items when mutating them. 11 | public struct Predicate { 12 | internal let matches: (Target) -> Bool 13 | 14 | /// Initialize a new predicate instance using a given matching closure. 15 | /// You can also create predicates based on operators and key paths. 16 | /// - parameter matcher: The matching closure to use. 17 | public init(matcher: @escaping (Target) -> Bool) { 18 | matches = matcher 19 | } 20 | } 21 | 22 | public extension Predicate { 23 | /// Create a predicate that matches any candidate. 24 | static var any: Self { Predicate { _ in true } } 25 | 26 | /// Create an inverse of this predicate - that is one that matches 27 | /// all candidates that this predicate does not, and vice versa. 28 | func inverse() -> Self { 29 | Predicate { !self.matches($0) } 30 | } 31 | } 32 | 33 | /// Create a predicate for comparing a key path against a value. 34 | /// Usage example: `\.path == "somePath"`. 35 | public func ==(lhs: KeyPath, rhs: V) -> Predicate { 36 | Predicate { $0[keyPath: lhs] == rhs } 37 | } 38 | 39 | /// Create a predicate for checking whether an element is contained 40 | /// within a collection-based key path's value. 41 | /// Usage example: `\.tags ~= "someTag"`. 42 | public func ~=( 43 | lhs: KeyPath, 44 | rhs: V.Element 45 | ) -> Predicate where V.Element: Equatable { 46 | Predicate { $0[keyPath: lhs].contains(rhs) } 47 | } 48 | 49 | /// Create a predicate that matches against `false` values for a given 50 | /// `Bool` key path. 51 | /// Usage example: `!\.isExplicit` 52 | public prefix func !(rhs: KeyPath) -> Predicate { 53 | rhs == false 54 | } 55 | 56 | /// Create a predicate that matches when a key path's value is 57 | /// higher than a given value. 58 | /// Usage example: `\.metadata.intValue > 3`. 59 | public func >(lhs: KeyPath, rhs: V) -> Predicate { 60 | Predicate { $0[keyPath: lhs] > rhs } 61 | } 62 | 63 | /// Create a predicate that matches when a key path's value is 64 | /// lower than a given value. 65 | /// Usage example: `\.metadata.intValue < 3`. 66 | public func <(lhs: KeyPath, rhs: V) -> Predicate { 67 | Predicate { $0[keyPath: lhs] < rhs } 68 | } 69 | 70 | /// Combine two predicates into one. Both of the underlying predicates 71 | /// have to match for the new predicate to match. 72 | public func &&(lhs: Predicate, rhs: Predicate) -> Predicate { 73 | Predicate { lhs.matches($0) && rhs.matches($0) } 74 | } 75 | 76 | /// Combine two predicates into one. Either of the underlying predicates 77 | /// has to match for the new predicate to match. 78 | public func ||(lhs: Predicate, rhs: Predicate) -> Predicate { 79 | Predicate { lhs.matches($0) || rhs.matches($0) } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Publish/API/PublishedWebsite.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type representing a fully published website. An instance of this type 10 | /// is returned from every call to `Website.publish()`, and can be used 11 | /// to implement additional tooling on top of Publish. 12 | public struct PublishedWebsite { 13 | /// The main website index that was published. 14 | public let index: Index 15 | /// The sections that were published. 16 | public let sections: SectionMap 17 | /// The free-form pages that were published. 18 | public let pages: [Path : Page] 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Publish/API/PublishingError.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Error type thrown as part of the website publishing process. 10 | public struct PublishingError: Equatable { 11 | /// Any step that the error was encountered during. 12 | public var stepName: String? 13 | /// Any path that the error was encountered at. 14 | public var path: Path? 15 | /// An info message that gives more context about the error. 16 | public let infoMessage: String 17 | /// Any underlying error message that this error is based on. 18 | public var underlyingErrorMessage: String? 19 | 20 | /// Initialize a new error instance. 21 | /// - Parameter stepName: Any step that the error was encountered during. 22 | /// - Parameter path: Any path that the error was encountered at. 23 | /// - Parameter infoMessage: An info message that gives more context about the error. 24 | /// - Parameter underlyingError: Any underlying error message that this error is based on. 25 | public init(stepName: String? = nil, 26 | path: Path? = nil, 27 | infoMessage: String, 28 | underlyingError: Error? = nil) { 29 | self.stepName = stepName 30 | self.path = path 31 | self.infoMessage = infoMessage 32 | self.underlyingErrorMessage = underlyingError?.localizedDescription 33 | } 34 | } 35 | 36 | extension PublishingError: LocalizedError, CustomStringConvertible { 37 | public var description: String { 38 | var message = "Publish encountered an error:" 39 | 40 | stepName.map { message.append("\n[step] \($0)") } 41 | path.map { message.append("\n[path] \($0)") } 42 | 43 | message.append("\n[info] \(infoMessage)") 44 | 45 | underlyingErrorMessage.map { message.append("\n[error] \($0)") } 46 | 47 | return message 48 | } 49 | 50 | public var errorDescription: String? { description } 51 | } 52 | 53 | internal protocol PublishingErrorConvertible { 54 | func publishingError(forStepNamed stepName: String?) -> PublishingError 55 | } 56 | 57 | extension PublishingError: PublishingErrorConvertible { 58 | func publishingError(forStepNamed stepName: String?) -> PublishingError { 59 | var error = self 60 | error.stepName = stepName 61 | return error 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Publish/API/RSSFeedConfiguration.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Plot 9 | 10 | /// Configuration type used to customize how an RSS feed is generated 11 | /// when using the `generateRSSFeed` step. To use a default implementation, 12 | /// use `RSSFeedConfiguration.default`. 13 | public struct RSSFeedConfiguration: FeedConfiguration { 14 | public var targetPath: Path 15 | public var ttlInterval: TimeInterval 16 | public var maximumItemCount: Int 17 | public var indentation: Indentation.Kind? 18 | 19 | /// Initialize a new configuration instance. 20 | /// - Parameter targetPath: The path that the feed should be generated at. 21 | /// - Parameter ttlInterval: The feed's TTL time interval. 22 | /// - Parameter maximumItemCount: The maximum number of items that the 23 | /// feed should contain. 24 | /// - Parameter indentation: How the feed should be indented. 25 | public init( 26 | targetPath: Path = .defaultForRSSFeed, 27 | ttlInterval: TimeInterval = 250, 28 | maximumItemCount: Int = 100, 29 | indentation: Indentation.Kind? = nil 30 | ) { 31 | self.targetPath = targetPath 32 | self.ttlInterval = ttlInterval 33 | self.maximumItemCount = maximumItemCount 34 | self.indentation = indentation 35 | } 36 | } 37 | 38 | public extension RSSFeedConfiguration { 39 | /// Create a default RSS feed configuration implementation. 40 | static var `default`: RSSFeedConfiguration { .init() } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Publish/API/Section.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type representing one of a website's top sections, as defined by 10 | /// its `SectionID` type. Each section can contain content of its own, 11 | /// as well as a list of items. To modify a given section, access it 12 | /// through the `sections` property on the current `PublishingContext`. 13 | public struct Section: Location { 14 | /// The section's ID, as defined by its `Website` implementation. 15 | public let id: Site.SectionID 16 | /// The items contained within the section. 17 | public private(set) var items = [Item]() 18 | /// The date of the last modified item within the section. 19 | public private(set) var lastItemModificationDate: Date? 20 | public var path: Path { Path(id.rawValue) } 21 | public var content = Content() 22 | 23 | internal var allTags: AnySequence { .init(itemIndexesByTag.keys) } 24 | 25 | private var itemIndexesByPath = [Path : Int]() 26 | private var itemIndexesByTag = [Tag : Set]() 27 | 28 | internal init(id: Site.SectionID) { 29 | self.id = id 30 | self.title = id.rawValue.capitalized 31 | } 32 | } 33 | 34 | public extension Section { 35 | /// Retrieve an item that's located within a given path within the section. 36 | /// - parameter path: The relative path of the item to retrieve. 37 | func item(at path: Path) -> Item? { 38 | itemIndexesByPath[path].map { items[$0] } 39 | } 40 | 41 | /// Retrieve all of the section's items that are tagged with a given tag. 42 | /// - parameter tag: The tag to retrieve all items for. 43 | func items(taggedWith tag: Tag) -> [Item] { 44 | guard let indexes = itemIndexesByTag[tag] else { 45 | return [] 46 | } 47 | 48 | return indexes.map { items[$0] } 49 | } 50 | 51 | /// Add an item to this section. 52 | /// - parameter path: The relative path to add an item at. 53 | /// - parameter metadata: The item's site-specific metadata. 54 | /// - parameter configure: A closure used to configure the new item. 55 | mutating func addItem( 56 | at path: Path, 57 | withMetadata metadata: Site.ItemMetadata, 58 | configure: (inout Item) throws -> Void 59 | ) rethrows { 60 | var item = Item(path: path, sectionID: id, metadata: metadata) 61 | try configure(&item) 62 | item.sectionID = id 63 | addItem(item) 64 | } 65 | 66 | /// Mutate one of the section's items. 67 | /// - parameter path: The path of the item to mutate. 68 | /// - parameter mutations: Closure containing the mutations to apply. 69 | /// - throws: An error if the item couldn't be found, or if the mutation failed. 70 | mutating func mutateItem(at path: Path, 71 | using mutations: Mutations>) throws { 72 | guard let index = itemIndexesByPath[path] else { 73 | throw ContentError(path: path, reason: .itemNotFound) 74 | } 75 | 76 | var item = items[index] 77 | try mutateItem(&item, at: index, using: mutations) 78 | items[index] = item 79 | } 80 | 81 | /// Mutate all of the section's items, optionally matching a given predicate. 82 | /// - Parameter predicate: Any predicate to filter the items based on. 83 | /// - Parameter mutations: Closure containing the mutations to apply. 84 | mutating func mutateItems(matching predicate: Predicate> = .any, 85 | using mutations: Mutations>) rethrows { 86 | items = try items.map { item in 87 | guard predicate.matches(item) else { 88 | return item 89 | } 90 | 91 | var item = item 92 | let index = itemIndexesByPath[item.relativePath]! 93 | try mutateItem(&item, at: index, using: mutations) 94 | return item 95 | } 96 | } 97 | 98 | /// Remove all items within this section matching a given predicate. 99 | /// - Parameter predicate: Any predicate to filter the items based on. 100 | mutating func removeItems(matching predicate: Predicate> = .any) { 101 | items.removeAll(where: predicate.matches) 102 | rebuildIndexes() 103 | } 104 | 105 | /// Sort all items within this section using a closure. 106 | /// - Parameter sorter: The closure to use to sort the items. 107 | mutating func sortItems(by sorter: (Item, Item) throws -> Bool) rethrows { 108 | try items.sort(by: sorter) 109 | rebuildIndexes() 110 | } 111 | } 112 | 113 | internal extension Section { 114 | mutating func addItem(_ item: Item) { 115 | let index = items.count 116 | itemIndexesByPath[item.relativePath] = index 117 | 118 | for tag in item.tags { 119 | itemIndexesByTag[tag, default: []].insert(index) 120 | } 121 | 122 | updateLastItemModificationDateIfNeeded(to: item.date) 123 | items.append(item) 124 | } 125 | 126 | mutating func replaceItems(with newItems: [Item]) { 127 | items = newItems 128 | rebuildIndexes() 129 | } 130 | } 131 | 132 | private extension Section { 133 | mutating func mutateItem( 134 | _ item: inout Item, 135 | at index: Int, 136 | using mutations: Mutations> 137 | ) throws { 138 | do { 139 | let oldTags = Set(item.tags) 140 | try mutations(&item) 141 | item.sectionID = id 142 | let newTags = Set(item.tags) 143 | 144 | if oldTags != newTags { 145 | for tag in oldTags { 146 | if !newTags.contains(tag), var indexes = itemIndexesByTag[tag] { 147 | indexes.remove(index) 148 | itemIndexesByTag[tag] = indexes.isEmpty ? nil : indexes 149 | } 150 | } 151 | 152 | for tag in newTags { 153 | if !oldTags.contains(tag) { 154 | itemIndexesByTag[tag, default: []].insert(index) 155 | } 156 | } 157 | } 158 | 159 | updateLastItemModificationDateIfNeeded(to: item.date) 160 | } catch { 161 | throw ContentError( 162 | path: item.path, 163 | reason: .itemMutationFailed(error) 164 | ) 165 | } 166 | } 167 | 168 | mutating func updateLastItemModificationDateIfNeeded(to newDate: Date) { 169 | if let previous = lastItemModificationDate { 170 | guard newDate > previous else { return } 171 | } 172 | 173 | lastItemModificationDate = newDate 174 | } 175 | 176 | mutating func rebuildIndexes() { 177 | itemIndexesByPath = [:] 178 | itemIndexesByTag = [:] 179 | 180 | for (index, item) in items.enumerated() { 181 | itemIndexesByPath[item.relativePath] = index 182 | 183 | for tag in item.tags { 184 | itemIndexesByTag[tag, default: []].insert(index) 185 | } 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /Sources/Publish/API/SectionMap.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// A map type containing all sections within a given website. 10 | /// You access an instance of this type through the current `PublishingContext`. 11 | public struct SectionMap { 12 | /// The IDs of all the sections contained within this map, in the order 13 | /// they were defined within the site's `SectionID` enum. 14 | public var ids: Site.SectionID.AllCases { Site.SectionID.allCases } 15 | private var sections = [Site.SectionID : Section]() 16 | 17 | internal init() { 18 | for id in Site.SectionID.allCases { 19 | sections[id] = Section(id: id) 20 | } 21 | } 22 | 23 | public subscript(id: Site.SectionID) -> Section { 24 | get { sections[id]! } 25 | set { sections[newValue.id] = newValue } 26 | } 27 | } 28 | 29 | extension SectionMap: Sequence { 30 | public func makeIterator() -> AnyIterator> { 31 | var ids = self.ids.makeIterator() 32 | 33 | return AnyIterator { 34 | guard let nextID = ids.next() else { return nil } 35 | return self[nextID] 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Sources/Publish/API/SortOrder.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | /// Enum describing various orders that can be used when 8 | /// performing sorting operations. 9 | public enum SortOrder { 10 | /// Sort the collection in ascending order. 11 | case ascending 12 | /// Sort the collection in descending order. 13 | case descending 14 | } 15 | 16 | internal extension SortOrder { 17 | func makeSorter( 18 | forKeyPath keyPath: KeyPath 19 | ) -> (T, T) -> Bool { 20 | switch self { 21 | case .ascending: 22 | return { 23 | $0[keyPath: keyPath] < $1[keyPath: keyPath] 24 | } 25 | case .descending: 26 | return { 27 | $0[keyPath: keyPath] > $1[keyPath: keyPath] 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sources/Publish/API/StringWrapper.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Codextended 8 | 9 | /// Protocol adopted by types that act as type-safe wrappers around strings. 10 | public protocol StringWrapper: CustomStringConvertible, 11 | ExpressibleByStringInterpolation, 12 | Codable, 13 | Hashable, 14 | Comparable { 15 | /// The underlying string value backing this instance. 16 | var string: String { get } 17 | /// Initialize a new instance with an underlying string value. 18 | /// - parameter string: The string to form a new value from. 19 | init(_ string: String) 20 | } 21 | 22 | public extension StringWrapper { 23 | static func <(lhs: Self, rhs: Self) -> Bool { 24 | lhs.string < rhs.string 25 | } 26 | 27 | var description: String { string } 28 | 29 | init(stringLiteral value: String) { 30 | self.init(value) 31 | } 32 | 33 | init(from decoder: Decoder) throws { 34 | try self.init(decoder.decodeSingleValue()) 35 | } 36 | 37 | func encode(to encoder: Encoder) throws { 38 | try encoder.encodeSingleValue(string) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Publish/API/Tag.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// Type used to represent a content tag. Items may be tagged, and then 10 | /// retrieved based on any tag that they were associated with. 11 | public struct Tag: StringWrapper { 12 | public var string: String 13 | 14 | public init(_ string: String) { 15 | self.string = string 16 | } 17 | } 18 | 19 | public extension Tag { 20 | /// Return a normalized string representation of this tag, which can 21 | /// be used to form URLs or identifiers. 22 | func normalizedString() -> String { 23 | string.normalized() 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/Publish/API/TagDetailsPage.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | /// A representation of a page that contains details about a given tag. 10 | public struct TagDetailsPage: Location { 11 | /// The tag that the details page is for. 12 | public var tag: Tag 13 | public let path: Path 14 | public var content: Content 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Publish/API/TagHTMLConfiguration.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | /// Configuration type used to customize how a website's 8 | /// tag page gets rendered. To use a default implementation, 9 | /// use `TagHTMLConfiguration.default`. 10 | public struct TagHTMLConfiguration { 11 | /// The based path of all of the site's tag HTML. 12 | public var basePath: Path 13 | /// Any content that should be added to the site's tag list page. 14 | public var listContent: Content? 15 | /// Any closure used to resolve content for each tag details page. 16 | public var detailsContentResolver: (Tag) -> Content? 17 | 18 | /// Initialize a new configuration instance. 19 | /// - Parameter basePath: The based path of all of the site's tag HTML. 20 | /// - Parameter listContent: The site's tag list page content. 21 | /// - Parameter detailsContentResolver: Any closure used to resolve 22 | /// content for each tag details page. 23 | public init( 24 | basePath: Path = .defaultForTagHTML, 25 | listContent: Content? = nil, 26 | detailsContentResolver: @escaping (Tag) -> Content? = { _ in nil } 27 | ) { 28 | self.basePath = basePath 29 | self.listContent = listContent 30 | self.detailsContentResolver = detailsContentResolver 31 | } 32 | } 33 | 34 | public extension TagHTMLConfiguration { 35 | /// Create a default tag HTML configuration implementation. 36 | static var `default`: Self { .init() } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/Publish/API/TagListPage.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | /// A representation of the page that contains all of a website's tags. 8 | public struct TagListPage: Location { 9 | /// All of the tags used within the website. 10 | public var tags: Set 11 | public let path: Path 12 | public var content: Content 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Publish/API/Theme.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Plot 8 | 9 | /// Type used to implement an HTML theme. 10 | /// When implementing reusable themes that are vended as frameworks or APIs, 11 | /// it's recommended to create them using static factory methods, just like 12 | /// how the built-in `foundation` theme is implemented. 13 | public struct Theme { 14 | internal let makeIndexHTML: (Index, PublishingContext) throws -> HTML 15 | internal let makeSectionHTML: (Section, PublishingContext) throws -> HTML 16 | internal let makeItemHTML: (Item, PublishingContext) throws -> HTML 17 | internal let makePageHTML: (Page, PublishingContext) throws -> HTML 18 | internal let makeTagListHTML: (TagListPage, PublishingContext) throws -> HTML? 19 | internal let makeTagDetailsHTML: (TagDetailsPage, PublishingContext) throws -> HTML? 20 | internal let resourcePaths: Set 21 | internal let creationPath: Path 22 | 23 | /// Create a new theme instance. 24 | /// - parameter factory: The HTML factory to use to create the theme's HTML. 25 | /// - parameter resources: A set of paths to any resources that the theme uses. 26 | /// These resources will be copied into the website's output folder before 27 | /// the theme is used, and should be relative to the root folder of the Swift 28 | /// package that this theme is defined in. 29 | /// - parameter file: The file that this method is called from (auto-inserted). 30 | public init( 31 | htmlFactory factory: T, 32 | resourcePaths resources: Set = [], 33 | file: StaticString = #file 34 | ) where T.Site == Site { 35 | makeIndexHTML = factory.makeIndexHTML 36 | makeSectionHTML = factory.makeSectionHTML 37 | makeItemHTML = factory.makeItemHTML 38 | makePageHTML = factory.makePageHTML 39 | makeTagListHTML = factory.makeTagListHTML 40 | makeTagDetailsHTML = factory.makeTagDetailsHTML 41 | resourcePaths = resources 42 | creationPath = Path("\(file)") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Publish/API/Video.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Plot 9 | 10 | /// A representation of a location's video data. Can be used to implement 11 | /// inline video players using the `videoPlayer` Plot component. 12 | public enum Video: Hashable { 13 | /// A self-hosted video located at a given URL. 14 | case hosted(url: URL, format: HTMLVideoFormat = .mp4) 15 | /// A YouTube video with a given ID. 16 | case youTube(id: String) 17 | /// A Vimeo video with a given ID. 18 | case vimeo(id: String) 19 | } 20 | 21 | extension Video: Decodable { 22 | public init(from decoder: Decoder) throws { 23 | if let youTubeID: String = try decoder.decodeIfPresent("youTube") { 24 | self = .youTube(id: youTubeID) 25 | } else if let vimeoID: String = try decoder.decodeIfPresent("vimeo") { 26 | self = .vimeo(id: vimeoID) 27 | } else { 28 | self = try .hosted( 29 | url: decoder.decode("url"), 30 | format: decoder.decodeIfPresent("format") ?? .mp4 31 | ) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/Array+Appending.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | internal extension Array { 8 | func appending(_ element: Element) -> Self { 9 | var array = self 10 | array.append(element) 11 | return array 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/CommandLine+Output.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | internal extension CommandLine { 10 | enum OutputKind { 11 | case info 12 | case warning 13 | case error 14 | case success 15 | } 16 | 17 | static func output(_ string: String, as kind: OutputKind) { 18 | var string = string + "\n" 19 | 20 | if let emoji = kind.emoji { 21 | string = "\(emoji) \(string)" 22 | } 23 | 24 | fputs(string, kind.target) 25 | } 26 | } 27 | 28 | private extension CommandLine.OutputKind { 29 | var emoji: Character? { 30 | switch self { 31 | case .info: 32 | return nil 33 | case .warning: 34 | return "⚠️" 35 | case .error: 36 | return "❌" 37 | case .success: 38 | return "✅" 39 | } 40 | } 41 | 42 | var target: UnsafeMutablePointer { 43 | switch self { 44 | case .info, .warning, .success: 45 | return stdout 46 | case .error: 47 | return stdout 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/ContentError.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | internal struct ContentError: Error { 8 | var path: Path 9 | var reason: Reason 10 | } 11 | 12 | extension ContentError { 13 | enum Reason { 14 | case itemNotFound 15 | case itemMutationFailed(Error) 16 | case pageNotFound 17 | case pageMutationFailed(Error) 18 | case markdownMetadataDecodingFailed( 19 | context: DecodingError.Context?, 20 | valueFound: Bool 21 | ) 22 | } 23 | } 24 | 25 | extension ContentError: PublishingErrorConvertible { 26 | func publishingError(forStepNamed stepName: String?) -> PublishingError { 27 | PublishingError( 28 | stepName: stepName, 29 | path: path, 30 | infoMessage: infoMessage, 31 | underlyingError: underlyingError 32 | ) 33 | } 34 | } 35 | 36 | private extension ContentError { 37 | var infoMessage: String { 38 | switch reason { 39 | case .itemNotFound: 40 | return "No item found at '\(path)'." 41 | case .itemMutationFailed: 42 | return "Item mutation failed" 43 | case .pageNotFound: 44 | return "Page not found" 45 | case .pageMutationFailed: 46 | return "Page mutation failed" 47 | case .markdownMetadataDecodingFailed(let context, let valueFound): 48 | let key = context?.codingPath.map({ $0.stringValue }).joined(separator: ".") 49 | let keyString = key.map { "key '\($0)'" } ?? "unknown key" 50 | let adjective = valueFound ? "Invalid" : "Missing" 51 | return "\(adjective) metadata value for \(keyString)" 52 | } 53 | } 54 | 55 | var underlyingError: Error? { 56 | switch reason { 57 | case .itemNotFound, .pageNotFound, .markdownMetadataDecodingFailed: 58 | return nil 59 | case .itemMutationFailed(let error), .pageMutationFailed(let error): 60 | return error 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/File+SwiftPackageFolder.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Files 8 | 9 | internal extension File { 10 | func resolveSwiftPackageFolder() throws -> Folder { 11 | var nextFolder = parent 12 | 13 | while let currentFolder = nextFolder { 14 | if currentFolder.containsFile(named: "Package.swift") { 15 | return currentFolder 16 | } 17 | 18 | nextFolder = currentFolder.parent 19 | } 20 | 21 | throw PublishingError( 22 | path: Path(path), 23 | infoMessage: "Could not resolve Swift package folder" 24 | ) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/FileIOError.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | internal struct FileIOError: Error { 8 | var path: Path 9 | var reason: Reason 10 | } 11 | 12 | extension FileIOError { 13 | enum Reason { 14 | case rootFolderNotFound 15 | case folderNotFound 16 | case folderCreationFailed 17 | case folderCopyingFailed 18 | case fileNotFound 19 | case fileCreationFailed 20 | case fileCouldNotBeRead 21 | case fileCopyingFailed 22 | case deploymentFolderSetupFailed(Error) 23 | } 24 | } 25 | 26 | extension FileIOError: PublishingErrorConvertible { 27 | func publishingError(forStepNamed stepName: String?) -> PublishingError { 28 | PublishingError( 29 | stepName: stepName, 30 | path: path, 31 | infoMessage: infoMessage, 32 | underlyingError: underlyingError 33 | ) 34 | } 35 | } 36 | 37 | private extension FileIOError { 38 | var infoMessage: String { 39 | switch reason { 40 | case .rootFolderNotFound: 41 | return "The project's root folder could not be found" 42 | case .folderNotFound: 43 | return "Folder not found" 44 | case .folderCreationFailed: 45 | return "Failed to create folder" 46 | case .folderCopyingFailed: 47 | return "The folder could not be copied" 48 | case .fileNotFound: 49 | return "File not found" 50 | case .fileCreationFailed: 51 | return "Failed to create file" 52 | case .fileCouldNotBeRead: 53 | return "The file could not be read" 54 | case .fileCopyingFailed: 55 | return "The file could not be copied" 56 | case .deploymentFolderSetupFailed: 57 | return "Failed to setup deployment folder." 58 | } 59 | } 60 | 61 | var underlyingError: Error? { 62 | switch reason { 63 | case .rootFolderNotFound, .folderNotFound, 64 | .folderCreationFailed, .folderCopyingFailed, 65 | .fileNotFound, .fileCreationFailed, 66 | .fileCouldNotBeRead, .fileCopyingFailed: 67 | return nil 68 | case .deploymentFolderSetupFailed(let error): 69 | return error 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/Folder+Group.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Files 8 | 9 | internal extension Folder { 10 | struct Group { 11 | let root: Folder 12 | let output: Folder 13 | let `internal`: Folder 14 | let caches: Folder 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/HTMLGenerator.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Plot 8 | import Files 9 | import CollectionConcurrencyKit 10 | 11 | internal struct HTMLGenerator { 12 | let theme: Theme 13 | let indentation: Indentation.Kind? 14 | let fileMode: HTMLFileMode 15 | let context: PublishingContext 16 | 17 | func generate() async throws { 18 | try await withThrowingTaskGroup(of: Void.self) { group in 19 | group.addTask { try await copyThemeResources() } 20 | group.addTask { try generateIndexHTML() } 21 | group.addTask { try await generateSectionHTML() } 22 | group.addTask { try await generatePageHTML() } 23 | group.addTask { try await generateTagHTMLIfNeeded() } 24 | 25 | // Throw any errors generated by the above set of operations: 26 | for try await _ in group {} 27 | } 28 | } 29 | } 30 | 31 | private extension HTMLGenerator { 32 | func copyThemeResources() async throws { 33 | guard !theme.resourcePaths.isEmpty else { 34 | return 35 | } 36 | 37 | let creationFile = try File(path: theme.creationPath.string) 38 | let packageFolder = try creationFile.resolveSwiftPackageFolder() 39 | 40 | try await theme.resourcePaths.concurrentForEach { path in 41 | do { 42 | let file = try packageFolder.file(at: path.string) 43 | try context.copyFileToOutput(file, targetFolderPath: nil) 44 | } catch { 45 | throw PublishingError( 46 | path: path, 47 | infoMessage: "Failed to copy theme resource", 48 | underlyingError: error 49 | ) 50 | } 51 | } 52 | } 53 | 54 | func generateIndexHTML() throws { 55 | let html = try theme.makeIndexHTML(context.index, context) 56 | let indexFile = try context.createOutputFile(at: "index.html") 57 | try indexFile.write(html.render(indentedBy: indentation)) 58 | } 59 | 60 | func generateSectionHTML() async throws { 61 | try await context.sections.concurrentForEach { section in 62 | try outputHTML( 63 | for: section, 64 | indentedBy: indentation, 65 | using: theme.makeSectionHTML, 66 | fileMode: .foldersAndIndexFiles 67 | ) 68 | 69 | try await section.items.concurrentForEach { item in 70 | try outputHTML( 71 | for: item, 72 | indentedBy: indentation, 73 | using: theme.makeItemHTML, 74 | fileMode: fileMode 75 | ) 76 | } 77 | } 78 | } 79 | 80 | func generatePageHTML() async throws { 81 | try await context.pages.values.concurrentForEach { page in 82 | try outputHTML( 83 | for: page, 84 | indentedBy: indentation, 85 | using: theme.makePageHTML, 86 | fileMode: fileMode 87 | ) 88 | } 89 | } 90 | 91 | func generateTagHTMLIfNeeded() async throws { 92 | guard let config = context.site.tagHTMLConfig else { 93 | return 94 | } 95 | 96 | let listPage = TagListPage( 97 | tags: context.allTags, 98 | path: config.basePath, 99 | content: config.listContent ?? .init() 100 | ) 101 | 102 | if let listHTML = try theme.makeTagListHTML(listPage, context) { 103 | let listPath = Path("\(config.basePath)/index.html") 104 | let listFile = try context.createOutputFile(at: listPath) 105 | try listFile.write(listHTML.render(indentedBy: indentation)) 106 | } 107 | 108 | try await context.allTags.concurrentForEach { tag in 109 | let detailsPath = context.site.path(for: tag) 110 | let detailsContent = config.detailsContentResolver(tag) 111 | 112 | let detailsPage = TagDetailsPage( 113 | tag: tag, 114 | path: detailsPath, 115 | content: detailsContent ?? .init() 116 | ) 117 | 118 | guard let detailsHTML = try theme.makeTagDetailsHTML(detailsPage, context) else { 119 | return 120 | } 121 | 122 | try outputHTML( 123 | for: detailsPage, 124 | indentedBy: indentation, 125 | using: { _, _ in detailsHTML }, 126 | fileMode: fileMode 127 | ) 128 | } 129 | } 130 | 131 | func outputHTML( 132 | for location: T, 133 | indentedBy indentation: Indentation.Kind?, 134 | using generator: (T, PublishingContext) throws -> HTML, 135 | fileMode: HTMLFileMode 136 | ) throws { 137 | let html = try generator(location, context) 138 | let path = filePath(for: location, fileMode: fileMode) 139 | let file = try context.createOutputFile(at: path) 140 | try file.write(html.render(indentedBy: indentation)) 141 | } 142 | 143 | func filePath(for location: Location, fileMode: HTMLFileMode) -> Path { 144 | switch fileMode { 145 | case .foldersAndIndexFiles: 146 | return "\(location.path)/index.html" 147 | case .standAloneFiles: 148 | return "\(location.path).html" 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/MarkdownContentFactory.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Ink 9 | import Files 10 | import Codextended 11 | 12 | internal struct MarkdownContentFactory { 13 | let parser: MarkdownParser 14 | let dateFormatter: DateFormatter 15 | 16 | func makeContent(fromFile file: File) throws -> Content { 17 | let markdown = try parser.parse(file.readAsString()) 18 | let decoder = makeMetadataDecoder(for: markdown) 19 | return try makeContent(fromMarkdown: markdown, file: file, decoder: decoder) 20 | } 21 | 22 | func makeItem(fromFile file: File, 23 | at path: Path, 24 | sectionID: Site.SectionID) throws -> Item { 25 | let markdown = try parser.parse(file.readAsString()) 26 | let decoder = makeMetadataDecoder(for: markdown) 27 | 28 | let metadata = try Site.ItemMetadata(from: decoder) 29 | let path = try decoder.decodeIfPresent("path", as: Path.self) ?? path 30 | let tags = try decoder.decodeIfPresent("tags", as: [Tag].self) 31 | let content = try makeContent(fromMarkdown: markdown, file: file, decoder: decoder) 32 | let rssProperties = try decoder.decodeIfPresent("rss", as: ItemRSSProperties.self) 33 | 34 | return Item( 35 | path: path, 36 | sectionID: sectionID, 37 | metadata: metadata, 38 | tags: tags ?? [], 39 | content: content, 40 | rssProperties: rssProperties ?? .init() 41 | ) 42 | } 43 | 44 | func makePage(fromFile file: File, at path: Path) throws -> Page { 45 | let markdown = try parser.parse(file.readAsString()) 46 | let decoder = makeMetadataDecoder(for: markdown) 47 | let content = try makeContent(fromMarkdown: markdown, file: file, decoder: decoder) 48 | return Page(path: path, content: content) 49 | } 50 | } 51 | 52 | private extension MarkdownContentFactory { 53 | func makeMetadataDecoder(for markdown: Ink.Markdown) -> MarkdownMetadataDecoder { 54 | MarkdownMetadataDecoder( 55 | metadata: markdown.metadata, 56 | dateFormatter: dateFormatter 57 | ) 58 | } 59 | 60 | func makeContent(fromMarkdown markdown: Ink.Markdown, 61 | file: File, 62 | decoder: MarkdownMetadataDecoder) throws -> Content { 63 | let title = try decoder.decodeIfPresent("title", as: String.self) 64 | let description = try decoder.decodeIfPresent("description", as: String.self) 65 | let date = try resolvePublishingDate(fromFile: file, decoder: decoder) 66 | let lastModified = file.modificationDate ?? date 67 | let imagePath = try decoder.decodeIfPresent("image", as: Path.self) 68 | let audio = try decoder.decodeIfPresent("audio", as: Audio.self) 69 | let video = try decoder.decodeIfPresent("video", as: Video.self) 70 | 71 | return Content( 72 | title: title ?? markdown.title ?? file.nameExcludingExtension, 73 | description: description ?? "", 74 | body: Content.Body(html: markdown.html), 75 | date: date, 76 | lastModified: lastModified, 77 | imagePath: imagePath, 78 | audio: audio, 79 | video: video 80 | ) 81 | } 82 | 83 | func resolvePublishingDate(fromFile file: File, 84 | decoder: MarkdownMetadataDecoder) throws -> Date { 85 | if let date = try decoder.decodeIfPresent("date", as: Date.self) { 86 | return date 87 | } 88 | 89 | return file.modificationDate ?? Date() 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/MarkdownFileHandler.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Files 8 | import CollectionConcurrencyKit 9 | 10 | internal struct MarkdownFileHandler { 11 | func addMarkdownFiles( 12 | in folder: Folder, 13 | to context: inout PublishingContext 14 | ) async throws { 15 | let factory = context.makeMarkdownContentFactory() 16 | 17 | if let indexFile = try? folder.file(named: "index.md") { 18 | do { 19 | context.index.content = try factory.makeContent(fromFile: indexFile) 20 | } catch { 21 | throw wrap(error, forPath: "\(folder.path)index.md") 22 | } 23 | } 24 | 25 | let folderResults: [FolderResult] = try await folder.subfolders.concurrentMap { subfolder in 26 | guard let sectionID = Site.SectionID(rawValue: subfolder.name.lowercased()) else { 27 | return try await .pages(makePagesForMarkdownFiles( 28 | inFolder: subfolder, 29 | recursively: true, 30 | parentPath: Path(subfolder.name), 31 | factory: factory 32 | )) 33 | } 34 | 35 | var sectionContent: Content? 36 | 37 | let items: [Item] = try await subfolder.files.recursive.concurrentCompactMap { file in 38 | guard file.isMarkdown else { return nil } 39 | 40 | if file.nameExcludingExtension == "index", file.parent == subfolder { 41 | sectionContent = try factory.makeContent(fromFile: file) 42 | return nil 43 | } 44 | 45 | do { 46 | let fileName = file.nameExcludingExtension 47 | let path: Path 48 | 49 | if let parentPath = file.parent?.path(relativeTo: subfolder) { 50 | path = Path(parentPath).appendingComponent(fileName) 51 | } else { 52 | path = Path(fileName) 53 | } 54 | 55 | return try factory.makeItem( 56 | fromFile: file, 57 | at: path, 58 | sectionID: sectionID 59 | ) 60 | } catch { 61 | let path = Path(file.path(relativeTo: folder)) 62 | throw wrap(error, forPath: path) 63 | } 64 | } 65 | 66 | return .section(id: sectionID, content: sectionContent, items: items) 67 | } 68 | 69 | for result in folderResults { 70 | switch result { 71 | case .pages(let pages): 72 | for page in pages { 73 | context.addPage(page) 74 | } 75 | case .section(let id, let content, let items): 76 | if let content = content { 77 | context.sections[id].content = content 78 | } 79 | 80 | for item in items { 81 | context.addItem(item) 82 | } 83 | } 84 | } 85 | 86 | let rootPages = try await makePagesForMarkdownFiles( 87 | inFolder: folder, 88 | recursively: false, 89 | parentPath: "", 90 | factory: factory 91 | ) 92 | 93 | for page in rootPages { 94 | context.addPage(page) 95 | } 96 | } 97 | } 98 | 99 | private extension MarkdownFileHandler { 100 | enum FolderResult { 101 | case pages([Page]) 102 | case section(id: Site.SectionID, content: Content?, items: [Item]) 103 | } 104 | 105 | func makePagesForMarkdownFiles( 106 | inFolder folder: Folder, 107 | recursively: Bool, 108 | parentPath: Path, 109 | factory: MarkdownContentFactory 110 | ) async throws -> [Page] { 111 | let pages: [Page] = try await folder.files.concurrentCompactMap { file in 112 | guard file.isMarkdown else { return nil } 113 | 114 | if file.nameExcludingExtension == "index", !recursively { 115 | return nil 116 | } 117 | 118 | let pagePath = parentPath.appendingComponent(file.nameExcludingExtension) 119 | return try factory.makePage(fromFile: file, at: pagePath) 120 | } 121 | 122 | guard recursively else { 123 | return pages 124 | } 125 | 126 | return try await pages + folder.subfolders.concurrentFlatMap { subfolder -> [Page] in 127 | let parentPath = parentPath.appendingComponent(subfolder.name) 128 | 129 | return try await makePagesForMarkdownFiles( 130 | inFolder: subfolder, 131 | recursively: true, 132 | parentPath: parentPath, 133 | factory: factory 134 | ) 135 | } 136 | } 137 | 138 | func wrap(_ error: Error, forPath path: Path) -> Error { 139 | if error is FilesError { 140 | return FileIOError(path: path, reason: .fileCouldNotBeRead) 141 | } else if let error = error as? DecodingError { 142 | switch error { 143 | case .keyNotFound(_, let context), 144 | .valueNotFound(_, let context): 145 | return ContentError( 146 | path: path, 147 | reason: .markdownMetadataDecodingFailed( 148 | context: context, 149 | valueFound: false 150 | ) 151 | ) 152 | case .typeMismatch(_, let context), 153 | .dataCorrupted(let context): 154 | return ContentError( 155 | path: path, 156 | reason: .markdownMetadataDecodingFailed( 157 | context: context, 158 | valueFound: true 159 | ) 160 | ) 161 | @unknown default: 162 | return ContentError( 163 | path: path, 164 | reason: .markdownMetadataDecodingFailed( 165 | context: nil, 166 | valueFound: true 167 | ) 168 | ) 169 | } 170 | } else { 171 | return error 172 | } 173 | } 174 | } 175 | 176 | private extension File { 177 | private static let markdownFileExtensions: Set = [ 178 | "md", "markdown", "txt", "text" 179 | ] 180 | 181 | var isMarkdown: Bool { 182 | self.extension.map(File.markdownFileExtensions.contains) ?? false 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/PodcastError.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | internal struct PodcastError: Error { 8 | var path: Path 9 | var reason: Reason 10 | } 11 | 12 | extension PodcastError { 13 | enum Reason { 14 | case missingAudio 15 | case missingAudioDuration 16 | case missingAudioSize 17 | case missingMetadata 18 | } 19 | } 20 | 21 | extension PodcastError: PublishingErrorConvertible { 22 | func publishingError(forStepNamed stepName: String?) -> PublishingError { 23 | PublishingError( 24 | stepName: stepName, 25 | path: path, 26 | infoMessage: infoMessage 27 | ) 28 | } 29 | 30 | private var infoMessage: String { 31 | switch reason { 32 | case .missingAudio: 33 | return "Podcast items need to include audio data" 34 | case .missingAudioSize: 35 | return "Podcast items need to include audio size info (audio.size)" 36 | case .missingAudioDuration: 37 | return "Podcast items need to include audio duration info (audio.duration)" 38 | case .missingMetadata: 39 | return "Podcast items need to define 'podcast' metadata" 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/PodcastFeedGenerator.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Plot 9 | import CollectionConcurrencyKit 10 | 11 | internal struct PodcastFeedGenerator where Site.ItemMetadata: PodcastCompatibleWebsiteItemMetadata { 12 | let sectionID: Site.SectionID 13 | let itemPredicate: Predicate>? 14 | let config: PodcastFeedConfiguration 15 | let context: PublishingContext 16 | let date: Date 17 | 18 | func generate() async throws { 19 | let outputFile = try context.createOutputFile(at: config.targetPath) 20 | let cacheFile = try context.cacheFile(named: "feed") 21 | let oldCache = try? cacheFile.read().decoded() as Cache 22 | let section = context.sections[sectionID] 23 | var items = section.items.sorted(by: { $0.date > $1.date }) 24 | 25 | if let predicate = itemPredicate?.inverse() { 26 | items.removeAll(where: predicate.matches) 27 | } 28 | 29 | if let date = context.lastGenerationDate, let cache = oldCache { 30 | if cache.config == config, cache.itemCount == items.count { 31 | let newlyModifiedItem = items.first { $0.lastModified > date } 32 | 33 | guard newlyModifiedItem != nil else { 34 | return try outputFile.write(cache.feed) 35 | } 36 | } 37 | } 38 | 39 | let feed = try await makeFeed(containing: items, section: section) 40 | .render(indentedBy: config.indentation) 41 | 42 | let newCache = Cache(config: config, feed: feed, itemCount: items.count) 43 | try cacheFile.write(newCache.encoded()) 44 | try outputFile.write(feed) 45 | } 46 | } 47 | 48 | private extension PodcastFeedGenerator { 49 | struct Cache: Codable { 50 | let config: PodcastFeedConfiguration 51 | let feed: String 52 | let itemCount: Int 53 | } 54 | 55 | func makeFeed(containing items: [Item], 56 | section: Section) async throws -> PodcastFeed { 57 | try PodcastFeed( 58 | .unwrap(config.newFeedURL, Node.newFeedURL), 59 | .title(context.site.name), 60 | .description(config.description), 61 | .link(context.site.url(for: section)), 62 | .language(context.site.language), 63 | .lastBuildDate(date, timeZone: context.dateFormatter.timeZone), 64 | .pubDate(date, timeZone: context.dateFormatter.timeZone), 65 | .ttl(Int(config.ttlInterval)), 66 | .atomLink(context.site.url(for: config.targetPath)), 67 | .copyright(config.copyrightText), 68 | .author(config.author.name), 69 | .subtitle(config.subtitle), 70 | .summary(config.description), 71 | .explicit(config.isExplicit), 72 | .owner( 73 | .name(config.author.name), 74 | .email(config.author.emailAddress) 75 | ), 76 | .category( 77 | config.category, 78 | .unwrap(config.subcategory) { .category($0) } 79 | ), 80 | .type(config.type), 81 | .image(config.imageURL), 82 | .group(await items.concurrentMap { item in 83 | guard let audio = item.audio else { 84 | throw PodcastError(path: item.path, reason: .missingAudio) 85 | } 86 | 87 | guard let audioDuration = audio.duration else { 88 | throw PodcastError(path: item.path, reason: .missingAudioDuration) 89 | } 90 | 91 | guard let audioSize = audio.byteSize else { 92 | throw PodcastError(path: item.path, reason: .missingAudioSize) 93 | } 94 | 95 | let title = item.rssTitle 96 | let metadata = item.metadata.podcast 97 | 98 | return .item( 99 | .guid(for: item, site: context.site), 100 | .title(title), 101 | .description(item.description), 102 | .link(context.site.url(for: item)), 103 | .pubDate(item.date, timeZone: context.dateFormatter.timeZone), 104 | .content(for: item, site: context.site), 105 | .author(config.author.name), 106 | .subtitle(item.description), 107 | .summary(item.description), 108 | .explicit(metadata?.isExplicit ?? false), 109 | .duration(audioDuration), 110 | .image(config.imageURL), 111 | .unwrap(metadata?.episodeNumber, Node.episodeNumber), 112 | .unwrap(metadata?.seasonNumber, Node.seasonNumber), 113 | .audio( 114 | url: audio.url, 115 | byteSize: audioSize, 116 | type: "audio/\(audio.format.rawValue)", 117 | title: title 118 | ) 119 | ) 120 | }) 121 | ) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/PublishingPipeline.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Files 8 | 9 | #if canImport(Cocoa) 10 | import Cocoa 11 | #endif 12 | 13 | internal struct PublishingPipeline { 14 | let steps: [PublishingStep] 15 | let originFilePath: Path 16 | } 17 | 18 | extension PublishingPipeline { 19 | func execute(for site: Site, at path: Path?) async throws -> PublishedWebsite { 20 | let stepKind = resolveStepKind() 21 | 22 | let folders = try setUpFolders( 23 | withExplicitRootPath: path, 24 | shouldEmptyOutputFolder: stepKind == .generation 25 | ) 26 | 27 | let steps = self.steps.flatMap { step in 28 | runnableSteps(ofKind: stepKind, from: step) 29 | } 30 | 31 | guard let firstStep = steps.first else { 32 | throw PublishingError( 33 | infoMessage: """ 34 | \(site.name) has no \(stepKind.rawValue) steps. 35 | """ 36 | ) 37 | } 38 | 39 | var context = PublishingContext( 40 | site: site, 41 | folders: folders, 42 | firstStepName: firstStep.name 43 | ) 44 | 45 | context.generationWillBegin() 46 | 47 | postNotification(named: "WillStart") 48 | CommandLine.output("Publishing \(site.name) (\(steps.count) steps)", as: .info) 49 | 50 | for (index, step) in steps.enumerated() { 51 | do { 52 | let message = "[\(index + 1)/\(steps.count)] \(step.name)" 53 | CommandLine.output(message, as: .info) 54 | context.prepareForStep(named: step.name) 55 | try await step.closure(&context) 56 | } catch let error as PublishingErrorConvertible { 57 | throw error.publishingError(forStepNamed: step.name) 58 | } catch { 59 | let message = "An unknown error occurred: \(error.localizedDescription)" 60 | throw PublishingError(infoMessage: message) 61 | } 62 | } 63 | 64 | CommandLine.output("Successfully published \(site.name)", as: .success) 65 | postNotification(named: "DidFinish") 66 | 67 | return PublishedWebsite( 68 | index: context.index, 69 | sections: context.sections, 70 | pages: context.pages 71 | ) 72 | } 73 | } 74 | 75 | private extension PublishingPipeline { 76 | typealias Step = PublishingStep 77 | 78 | struct RunnableStep { 79 | let name: String 80 | let closure: Step.Closure 81 | } 82 | 83 | func setUpFolders(withExplicitRootPath path: Path?, 84 | shouldEmptyOutputFolder: Bool) throws -> Folder.Group { 85 | let root = try resolveRootFolder(withExplicitPath: path) 86 | let outputFolderName = "Output" 87 | 88 | if shouldEmptyOutputFolder { 89 | try? root.subfolder(named: outputFolderName).empty(includingHidden: true) 90 | } 91 | 92 | do { 93 | let outputFolder = try root.createSubfolderIfNeeded( 94 | withName: outputFolderName 95 | ) 96 | 97 | let internalFolder = try root.createSubfolderIfNeeded( 98 | withName: ".publish" 99 | ) 100 | 101 | let cacheFolder = try internalFolder.createSubfolderIfNeeded( 102 | withName: "Caches" 103 | ) 104 | 105 | return Folder.Group( 106 | root: root, 107 | output: outputFolder, 108 | internal: internalFolder, 109 | caches: cacheFolder 110 | ) 111 | } catch { 112 | throw PublishingError( 113 | path: path, 114 | infoMessage: "Failed to set up root folder structure" 115 | ) 116 | } 117 | } 118 | 119 | func resolveRootFolder(withExplicitPath path: Path?) throws -> Folder { 120 | if let path = path { 121 | do { 122 | return try Folder(path: path.string) 123 | } catch { 124 | throw PublishingError( 125 | path: path, 126 | infoMessage: "Could not find the requested root folder" 127 | ) 128 | } 129 | } 130 | 131 | let originFile = try File(path: originFilePath.string) 132 | return try originFile.resolveSwiftPackageFolder() 133 | } 134 | 135 | func resolveStepKind() -> Step.Kind { 136 | let deploymentFlags: Set = ["--deploy", "-d"] 137 | let shouldDeploy = CommandLine.arguments.contains(where: deploymentFlags.contains) 138 | return shouldDeploy ? .deployment : .generation 139 | } 140 | 141 | func runnableSteps(ofKind kind: Step.Kind, from step: Step) -> [RunnableStep] { 142 | switch step.kind { 143 | case .system, kind: break 144 | default: return [] 145 | } 146 | 147 | switch step.body { 148 | case .empty: 149 | return [] 150 | case .group(let steps): 151 | return steps.flatMap { runnableSteps(ofKind: kind, from: $0) } 152 | case .operation(let name, let closure): 153 | return [RunnableStep(name: name, closure: closure)] 154 | } 155 | } 156 | 157 | func postNotification(named name: String) { 158 | #if canImport(Cocoa) 159 | let center = DistributedNotificationCenter.default() 160 | let name = Notification.Name(rawValue: "Publish.\(name)") 161 | center.post(Notification(name: name)) 162 | #endif 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/RSSFeedGenerator.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Plot 9 | import Files 10 | 11 | internal struct RSSFeedGenerator { 12 | let includedSectionIDs: Set 13 | let itemPredicate: Predicate>? 14 | let config: RSSFeedConfiguration 15 | let context: PublishingContext 16 | let date: Date 17 | 18 | func generate() async throws { 19 | let outputFile = try context.createOutputFile(at: config.targetPath) 20 | let cacheFile = try context.cacheFile(named: "feed") 21 | let oldCache = try? cacheFile.read().decoded() as Cache 22 | var items = [Item]() 23 | 24 | for sectionID in includedSectionIDs { 25 | items += context.sections[sectionID].items 26 | } 27 | 28 | items.sort { $0.date > $1.date } 29 | 30 | if let predicate = itemPredicate?.inverse() { 31 | items.removeAll(where: predicate.matches) 32 | } 33 | 34 | if let date = context.lastGenerationDate, let cache = oldCache { 35 | if cache.config == config, cache.itemCount == items.count { 36 | let newlyModifiedItem = items.first { $0.lastModified > date } 37 | 38 | guard newlyModifiedItem != nil else { 39 | return try outputFile.write(cache.feed) 40 | } 41 | } 42 | } 43 | 44 | let feed = await makeFeed(containing: items).render(indentedBy: config.indentation) 45 | 46 | let newCache = Cache(config: config, feed: feed, itemCount: items.count) 47 | try cacheFile.write(newCache.encoded()) 48 | try outputFile.write(feed) 49 | } 50 | } 51 | 52 | private extension RSSFeedGenerator { 53 | struct Cache: Codable { 54 | let config: RSSFeedConfiguration 55 | let feed: String 56 | let itemCount: Int 57 | } 58 | 59 | func makeFeed(containing items: [Item]) async -> RSS { 60 | RSS( 61 | .title(context.site.name), 62 | .description(context.site.description), 63 | .link(context.site.url), 64 | .language(context.site.language), 65 | .lastBuildDate(date, timeZone: context.dateFormatter.timeZone), 66 | .pubDate(date, timeZone: context.dateFormatter.timeZone), 67 | .ttl(Int(config.ttlInterval)), 68 | .atomLink(context.site.url(for: config.targetPath)), 69 | .group(await items.prefix(config.maximumItemCount).concurrentMap { item in 70 | .item( 71 | .guid(for: item, site: context.site), 72 | .title(item.rssTitle), 73 | .description(item.description), 74 | .link(item.rssProperties.link ?? context.site.url(for: item)), 75 | .pubDate(item.date, timeZone: context.dateFormatter.timeZone), 76 | .content(for: item, site: context.site) 77 | ) 78 | }) 79 | ) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/ShellOutError+PublishingErrorConvertible.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import ShellOut 8 | 9 | extension ShellOutError: PublishingErrorConvertible { 10 | func publishingError(forStepNamed stepName: String?) -> PublishingError { 11 | PublishingError( 12 | stepName: stepName, 13 | path: nil, 14 | infoMessage: message, 15 | underlyingError: nil 16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/SiteMapGenerator.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Plot 8 | 9 | struct SiteMapGenerator { 10 | let excludedPaths: Set 11 | let indentation: Indentation.Kind? 12 | let context: PublishingContext 13 | 14 | func generate() throws { 15 | let sections = context.sections.sorted { 16 | $0.id.rawValue < $1.id.rawValue 17 | } 18 | 19 | let pages = context.pages.values.sorted { 20 | $0.path < $1.path 21 | } 22 | 23 | let siteMap = makeSiteMap(for: sections, pages: pages, site: context.site) 24 | let xml = siteMap.render(indentedBy: indentation) 25 | let file = try context.createOutputFile(at: "sitemap.xml") 26 | try file.write(xml) 27 | } 28 | } 29 | 30 | private extension SiteMapGenerator { 31 | func shouldIncludePath(_ path: Path) -> Bool { 32 | !excludedPaths.contains(where: { 33 | path.string.hasPrefix($0.string) 34 | }) 35 | } 36 | 37 | func makeSiteMap(for sections: [Section], pages: [Page], site: Site) -> SiteMap { 38 | SiteMap( 39 | .forEach(sections) { section in 40 | guard shouldIncludePath(section.path) else { 41 | return .empty 42 | } 43 | 44 | return .group( 45 | .url( 46 | .loc(site.url(for: section)), 47 | .changefreq(.daily), 48 | .priority(1.0), 49 | .lastmod(max( 50 | section.lastModified, 51 | section.lastItemModificationDate ?? .distantPast 52 | )) 53 | ), 54 | .forEach(section.items) { item in 55 | guard shouldIncludePath(item.path) else { 56 | return .empty 57 | } 58 | 59 | return .url( 60 | .loc(site.url(for: item)), 61 | .changefreq(.monthly), 62 | .priority(0.5), 63 | .lastmod(item.lastModified) 64 | ) 65 | } 66 | ) 67 | }, 68 | .forEach(pages) { page in 69 | guard shouldIncludePath(page.path) else { 70 | return .empty 71 | } 72 | 73 | return .url( 74 | .loc(site.url(for: page)), 75 | .changefreq(.monthly), 76 | .priority(0.5), 77 | .lastmod(page.lastModified) 78 | ) 79 | } 80 | ) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Publish/Internal/String+Normalized.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2020 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | internal extension String { 8 | func normalized() -> String { 9 | String(lowercased().compactMap { character in 10 | if character.isWhitespace { 11 | return "-" 12 | } 13 | 14 | if character.isLetter || character.isNumber { 15 | return character 16 | } 17 | 18 | return nil 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/PublishCLI/main.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Publish 9 | import Files 10 | import ShellOut 11 | import Codextended 12 | import PublishCLICore 13 | 14 | let cli = CLI( 15 | publishRepositoryURL: URL( 16 | string: "https://github.com/johnsundell/publish.git" 17 | )!, 18 | publishVersion: "0.8.0" 19 | ) 20 | 21 | do { 22 | try cli.run() 23 | } catch { 24 | fputs("❌ \(error)\n", stderr) 25 | exit(1) 26 | } 27 | -------------------------------------------------------------------------------- /Sources/PublishCLICore/CLI.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Files 8 | import Foundation 9 | import ShellOut 10 | 11 | public struct CLI { 12 | private let arguments: [String] 13 | private let publishRepositoryURL: URL 14 | private let publishVersion: String 15 | 16 | public init(arguments: [String] = CommandLine.arguments, 17 | publishRepositoryURL: URL, 18 | publishVersion: String) { 19 | self.arguments = arguments 20 | self.publishRepositoryURL = publishRepositoryURL 21 | self.publishVersion = publishVersion 22 | } 23 | 24 | public func run(in folder: Folder = .current) throws { 25 | guard arguments.count > 1 else { 26 | return outputHelpText() 27 | } 28 | 29 | switch arguments[1] { 30 | case "new": 31 | let generator = ProjectGenerator( 32 | folder: folder, 33 | publishRepositoryURL: publishRepositoryURL, 34 | publishVersion: publishVersion, 35 | kind: resolveProjectKind(from: arguments) 36 | ) 37 | 38 | try generator.generate() 39 | case "generate": 40 | let generator = WebsiteGenerator(folder: folder) 41 | try generator.generate() 42 | case "deploy": 43 | let deployer = WebsiteDeployer(folder: folder) 44 | try deployer.deploy() 45 | case "run": 46 | let portNumber = extractPortNumber(from: arguments) 47 | let runner = WebsiteRunner(folder: folder, portNumber: portNumber) 48 | try runner.run() 49 | default: 50 | outputHelpText() 51 | } 52 | } 53 | } 54 | 55 | private extension CLI { 56 | func outputHelpText() { 57 | print(""" 58 | Publish Command Line Interface 59 | ------------------------------ 60 | Interact with the Publish static site generator from 61 | the command line, to create new websites, or to generate 62 | and deploy existing ones. 63 | 64 | Available commands: 65 | 66 | - new: Set up a new website in the current folder. 67 | - generate: Generate the website in the current folder. 68 | - run: Generate and run a localhost server on default port 8000 69 | for the website in the current folder. Use the "-p" 70 | or "--port" option for customizing the default port. 71 | - deploy: Generate and deploy the website in the current 72 | folder, according to its deployment method. 73 | """) 74 | } 75 | 76 | private func extractPortNumber(from arguments: [String]) -> Int { 77 | if arguments.count > 3 { 78 | switch arguments[2] { 79 | case "-p", "--port": 80 | guard let portNumber = Int(arguments[3]) else { 81 | break 82 | } 83 | return portNumber 84 | default: 85 | return 8000 // default portNumber 86 | } 87 | } 88 | return 8000 // default portNumber 89 | } 90 | 91 | private func resolveProjectKind(from arguments: [String]) -> ProjectKind { 92 | guard arguments.count > 2 else { 93 | return .website 94 | } 95 | 96 | return ProjectKind(rawValue: arguments[2]) ?? .website 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/PublishCLICore/CLIError.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | internal enum CLIError: Error { 8 | case newProjectFolderNotEmpty 9 | case notASwiftPackage 10 | case failedToResolveSwiftPackageName 11 | case outputFolderNotFound 12 | case failedToStartLocalhostServer(Error) 13 | } 14 | 15 | extension CLIError: CustomStringConvertible { 16 | var description: String { 17 | switch self { 18 | case .newProjectFolderNotEmpty: 19 | return "New projects can only be generated in empty folders." 20 | case .notASwiftPackage: 21 | return "The current folder is not a Swift package." 22 | case .failedToResolveSwiftPackageName: 23 | return "Failed to resolve the Swift package's name." 24 | case .outputFolderNotFound: 25 | return "The website's Output folder couldn't be found." 26 | case .failedToStartLocalhostServer(let error): 27 | return "Failed to start localhost server (\(error))" 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/PublishCLICore/Folder+SwiftPackage.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Files 8 | 9 | internal extension Folder { 10 | func verifyAsSwiftPackage() throws { 11 | guard containsFile(named: "Package.swift") else { 12 | throw CLIError.notASwiftPackage 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Sources/PublishCLICore/ProjectGenerator.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Files 9 | 10 | internal struct ProjectGenerator { 11 | private let folder: Folder 12 | private let publishRepositoryURL: URL 13 | private let publishVersion: String 14 | private let kind: ProjectKind 15 | private let name: String 16 | 17 | init(folder: Folder, 18 | publishRepositoryURL: URL, 19 | publishVersion: String, 20 | kind: ProjectKind) { 21 | self.folder = folder 22 | self.publishRepositoryURL = publishRepositoryURL 23 | self.publishVersion = publishVersion 24 | self.kind = kind 25 | self.name = folder.name.asProjectName() 26 | } 27 | 28 | func generate() throws { 29 | guard folder.files.first == nil, folder.subfolders.first == nil else { 30 | throw CLIError.newProjectFolderNotEmpty 31 | } 32 | 33 | try generateGitIgnore() 34 | try generatePackageFile() 35 | 36 | switch kind { 37 | case .website: 38 | try generateResourcesFolder() 39 | try generateContentFolder() 40 | try generateMainFile() 41 | case .plugin: 42 | try generatePluginBoilerplate() 43 | } 44 | 45 | print(""" 46 | ✅ Generated \(kind.rawValue) project for '\(name)' 47 | Run 'open Package.swift' to open it and start building 48 | """) 49 | } 50 | } 51 | 52 | private extension ProjectGenerator { 53 | func generateGitIgnore() throws { 54 | try folder.createFile(named: ".gitignore").write(""" 55 | .DS_Store 56 | /build 57 | /.build 58 | /.swiftpm 59 | /*.xcodeproj 60 | .publish 61 | """) 62 | } 63 | 64 | func generateResourcesFolder() throws { 65 | try folder.createSubfolder(named: "Resources") 66 | } 67 | 68 | func generateContentFolder() throws { 69 | let folder = try self.folder.createSubfolder(named: "Content") 70 | try folder.createIndexFile(withMarkdown: "# Welcome to \(name)!") 71 | 72 | let postsFolder = try folder.createSubfolder(named: "posts") 73 | try postsFolder.createIndexFile(withMarkdown: "# My posts") 74 | 75 | let dateFormatter = DateFormatter() 76 | dateFormatter.dateFormat = "yyyy-MM-dd HH:mm" 77 | dateFormatter.timeZone = .current 78 | 79 | try postsFolder.createFile(named: "first-post.md").write(""" 80 | --- 81 | date: \(dateFormatter.string(from: Date())) 82 | description: A description of my first post. 83 | tags: first, article 84 | --- 85 | # My first post 86 | 87 | My first post's text. 88 | """) 89 | } 90 | 91 | func generatePackageFile() throws { 92 | let dependencyString: String 93 | let repositoryURL = publishRepositoryURL.absoluteString 94 | 95 | if repositoryURL.hasPrefix("http") || repositoryURL.hasPrefix("git@") { 96 | dependencyString = """ 97 | url: "\(repositoryURL)", from: "\(publishVersion)" 98 | """ 99 | } else { 100 | dependencyString = "path: \"\(publishRepositoryURL.path)\"" 101 | } 102 | 103 | try folder.createFile(named: "Package.swift").write(""" 104 | // swift-tools-version:5.5 105 | 106 | import PackageDescription 107 | 108 | let package = Package( 109 | name: "\(name)", 110 | platforms: [.macOS(.v12)], 111 | products: [ 112 | .\(kind.buildProduct)( 113 | name: "\(name)", 114 | targets: ["\(name)"] 115 | ) 116 | ], 117 | dependencies: [ 118 | .package(name: "Publish", \(dependencyString)) 119 | ], 120 | targets: [ 121 | .executableTarget( 122 | name: "\(name)", 123 | dependencies: ["Publish"] 124 | ) 125 | ] 126 | ) 127 | """) 128 | } 129 | 130 | func generateMainFile() throws { 131 | let path = "Sources/\(name)/main.swift" 132 | 133 | let websiteProtocol = (name == "Website") ? "Publish.Website" : "Website" 134 | try folder.createFileIfNeeded(at: path).write(""" 135 | import Foundation 136 | import Publish 137 | import Plot 138 | 139 | // This type acts as the configuration for your website. 140 | struct \(name): \(websiteProtocol) { 141 | enum SectionID: String, WebsiteSectionID { 142 | // Add the sections that you want your website to contain here: 143 | case posts 144 | } 145 | 146 | struct ItemMetadata: WebsiteItemMetadata { 147 | // Add any site-specific metadata that you want to use here. 148 | } 149 | 150 | // Update these properties to configure your website: 151 | var url = URL(string: "https://your-website-url.com")! 152 | var name = "\(name)" 153 | var description = "A description of \(name)" 154 | var language: Language { .english } 155 | var imagePath: Path? { nil } 156 | } 157 | 158 | // This will generate your website using the built-in Foundation theme: 159 | try \(name)().publish(withTheme: .foundation) 160 | """) 161 | } 162 | 163 | func generatePluginBoilerplate() throws { 164 | let path = "Sources/\(name)/\(name).swift" 165 | let methodName = name[name.startIndex].lowercased() + name.dropFirst() 166 | 167 | try folder.createFileIfNeeded(at: path).write(""" 168 | import Publish 169 | 170 | public extension Plugin { 171 | /// Documentation for your plugin 172 | static func \(methodName)() -> Self { 173 | Plugin(name: "\(name)") { context in 174 | // Perform your plugin's work 175 | } 176 | } 177 | } 178 | """) 179 | } 180 | } 181 | 182 | private extension ProjectKind { 183 | var buildProduct: String { 184 | switch self { 185 | case .website: 186 | return "executable" 187 | case .plugin: 188 | return "library" 189 | } 190 | } 191 | } 192 | 193 | private extension Folder { 194 | func createIndexFile(withMarkdown markdown: String) throws { 195 | try createFile(named: "index.md").write(markdown) 196 | } 197 | } 198 | 199 | private extension String { 200 | func asProjectName() -> Self { 201 | let validCharacters = CharacterSet.alphanumerics 202 | let validEdgeCharacters = CharacterSet.letters 203 | let validSegments = trimmingCharacters(in: validEdgeCharacters.inverted) 204 | .components(separatedBy: validCharacters.inverted) 205 | 206 | guard 207 | let firstSegment = validSegments.first, 208 | !firstSegment.isEmpty else { 209 | return "SiteName" 210 | } 211 | 212 | return validSegments 213 | .map { $0.capitalizingFirstLetter() } 214 | .joined() 215 | } 216 | 217 | private func capitalizingFirstLetter() -> String { 218 | return prefix(1).capitalized + dropFirst() 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /Sources/PublishCLICore/ProjectKind.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | internal enum ProjectKind: String { 10 | case website 11 | case plugin 12 | } 13 | -------------------------------------------------------------------------------- /Sources/PublishCLICore/WebsiteDeployer.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Files 9 | import ShellOut 10 | import Codextended 11 | 12 | internal struct WebsiteDeployer { 13 | let folder: Folder 14 | 15 | func deploy() throws { 16 | let name = try resolvePackageName() 17 | 18 | try shellOut( 19 | to: "swift run \(name) --deploy", 20 | at: folder.path, 21 | outputHandle: FileHandle.standardOutput, 22 | errorHandle: FileHandle.standardError 23 | ) 24 | } 25 | } 26 | 27 | private extension WebsiteDeployer { 28 | struct PackageDescription: Decodable { 29 | var name: String 30 | } 31 | 32 | func resolvePackageName() throws -> String { 33 | try folder.verifyAsSwiftPackage() 34 | 35 | do { 36 | let string = try shellOut(to: "swift package describe --type json") 37 | let data = Data(string.utf8) 38 | let description = try data.decoded() as PackageDescription 39 | return description.name 40 | } catch { 41 | throw CLIError.failedToResolveSwiftPackageName 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/PublishCLICore/WebsiteGenerator.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Files 9 | import ShellOut 10 | 11 | internal struct WebsiteGenerator { 12 | let folder: Folder 13 | 14 | func generate() throws { 15 | try folder.verifyAsSwiftPackage() 16 | 17 | try shellOut( 18 | to: "swift run", 19 | at: folder.path, 20 | outputHandle: FileHandle.standardOutput, 21 | errorHandle: FileHandle.standardError 22 | ) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/PublishCLICore/WebsiteRunner.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Files 9 | import ShellOut 10 | 11 | internal struct WebsiteRunner { 12 | let folder: Folder 13 | var portNumber: Int 14 | 15 | func run() throws { 16 | let generator = WebsiteGenerator(folder: folder) 17 | try generator.generate() 18 | 19 | let outputFolder = try resolveOutputFolder() 20 | 21 | let serverQueue = DispatchQueue(label: "Publish.WebServer") 22 | let serverProcess = Process() 23 | 24 | print(""" 25 | 🌍 Starting web server at http://localhost:\(portNumber) 26 | 27 | Press ENTER to stop the server and exit 28 | """) 29 | 30 | serverQueue.async { 31 | do { 32 | _ = try shellOut( 33 | to: "python3 -m http.server \(self.portNumber)", 34 | at: outputFolder.path, 35 | process: serverProcess 36 | ) 37 | } catch let error as ShellOutError { 38 | self.outputServerErrorMessage(error.message) 39 | } catch { 40 | self.outputServerErrorMessage(error.localizedDescription) 41 | } 42 | 43 | serverProcess.terminate() 44 | exit(1) 45 | } 46 | 47 | _ = readLine() 48 | serverProcess.terminate() 49 | } 50 | } 51 | 52 | private extension WebsiteRunner { 53 | func resolveOutputFolder() throws -> Folder { 54 | do { return try folder.subfolder(named: "Output") } 55 | catch { throw CLIError.outputFolderNotFound } 56 | } 57 | 58 | func outputServerErrorMessage(_ message: String) { 59 | var message = message 60 | 61 | if message.hasPrefix("Traceback"), 62 | message.contains("Address already in use") { 63 | message = """ 64 | A localhost server is already running on port number \(portNumber). 65 | - Perhaps another 'publish run' session is running? 66 | - Publish uses Python's simple HTTP server, so to find any 67 | running processes, you can use either Activity Monitor 68 | or the 'ps' command and search for 'python'. You can then 69 | terminate any previous process in order to start a new one. 70 | """ 71 | } 72 | 73 | fputs("\n❌ Failed to start local web server:\n\(message)\n", stderr) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/AnyError.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | struct AnyError: LocalizedError { 10 | var errorDescription: String? 11 | 12 | init(_ string: String) { 13 | errorDescription = string 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/Assertions.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | 9 | func assertErrorThrown( 10 | _ expression: @autoclosure () throws -> T, 11 | _ expectedError: @autoclosure () -> E, 12 | file: StaticString = #file, 13 | line: UInt = #line 14 | ) { 15 | do { 16 | _ = try expression() 17 | XCTFail( 18 | "Expected an error to be thrown", 19 | file: file, 20 | line: line 21 | ) 22 | } catch let error as E { 23 | XCTAssertEqual( 24 | error, 25 | expectedError(), 26 | file: file, 27 | line: line 28 | ) 29 | } catch { 30 | XCTFail( 31 | "Unexpected error thrown: \(error)", 32 | file: file, 33 | line: line 34 | ) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/Files+Temporary.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Files 9 | 10 | extension Folder { 11 | static func createTemporary() throws -> Self { 12 | let parent = try createTestsFolder() 13 | return try parent.createSubfolder(named: .unique()) 14 | } 15 | } 16 | 17 | private extension Folder { 18 | static func createTestsFolder() throws -> Self { 19 | try Folder.temporary.createSubfolderIfNeeded(at: "PublishTests") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/HTMLFactoryMock.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Publish 8 | import Plot 9 | 10 | final class HTMLFactoryMock: HTMLFactory { 11 | typealias Closure = (T, PublishingContext) throws -> HTML 12 | 13 | var makeIndexHTML: Closure = { _, _ in HTML(.body()) } 14 | var makeSectionHTML: Closure> = { _, _ in HTML(.body()) } 15 | var makeItemHTML: Closure> = { _, _ in HTML(.body()) } 16 | var makePageHTML: Closure = { _, _ in HTML(.body()) } 17 | var makeTagListHTML: Closure? = { _, _ in HTML(.body()) } 18 | var makeTagDetailsHTML: Closure? = { _, _ in HTML(.body()) } 19 | 20 | func makeIndexHTML(for index: Index, 21 | context: PublishingContext) throws -> HTML { 22 | try makeIndexHTML(index, context) 23 | } 24 | 25 | func makeSectionHTML(for section: Section, 26 | context: PublishingContext) throws -> HTML { 27 | try makeSectionHTML(section, context) 28 | } 29 | 30 | func makeItemHTML(for item: Item, 31 | context: PublishingContext) throws -> HTML { 32 | try makeItemHTML(item, context) 33 | } 34 | 35 | func makePageHTML(for page: Page, 36 | context: PublishingContext) throws -> HTML { 37 | try makePageHTML(page, context) 38 | } 39 | 40 | func makeTagListHTML(for page: TagListPage, 41 | context: PublishingContext) throws -> HTML? { 42 | try makeTagListHTML?(page, context) 43 | } 44 | 45 | func makeTagDetailsHTML(for page: TagDetailsPage, 46 | context: PublishingContext) throws -> HTML? { 47 | try makeTagDetailsHTML?(page, context) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/Item+Stubbable.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Publish 9 | 10 | extension Item: Stubbable where Site == WebsiteStub.WithoutItemMetadata { 11 | private static let defaultDate = Date() 12 | 13 | static func stub(withPath path: Path) -> Self { 14 | Item( 15 | path: path, 16 | sectionID: .one, 17 | metadata: Site.ItemMetadata(), 18 | tags: [], 19 | content: Content( 20 | date: defaultDate, 21 | lastModified: defaultDate 22 | ) 23 | ) 24 | } 25 | 26 | static func stub(withSectionID sectionID: WebsiteStub.SectionID) -> Self { 27 | stub(withPath: Path(.unique()), sectionID: sectionID) 28 | } 29 | 30 | static func stub(withPath path: Path, sectionID: WebsiteStub.SectionID) -> Self { 31 | Item( 32 | path: path, 33 | sectionID: sectionID, 34 | metadata: Site.ItemMetadata(), 35 | tags: [], 36 | content: Content( 37 | date: defaultDate, 38 | lastModified: defaultDate 39 | ) 40 | ) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/Page+Stubbable.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Publish 9 | 10 | extension Page: Stubbable { 11 | private static let defaultDate = Date() 12 | 13 | static func stub(withPath path: Path) -> Self { 14 | Page( 15 | path: path, 16 | content: Content( 17 | date: defaultDate, 18 | lastModified: defaultDate 19 | ) 20 | ) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/PublishTestCase.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Plot 10 | import Files 11 | 12 | class PublishTestCase: XCTestCase { 13 | @discardableResult 14 | func publishWebsite( 15 | in folder: Folder? = nil, 16 | using steps: [PublishingStep], 17 | content: [Path : String] = [:] 18 | ) throws -> PublishedWebsite { 19 | try performWebsitePublishing( 20 | in: folder, 21 | using: steps, 22 | files: content, 23 | filePathPrefix: "Content/" 24 | ) 25 | } 26 | 27 | func publishWebsite( 28 | _ site: WebsiteStub.WithoutItemMetadata = .init(), 29 | in folder: Folder? = nil, 30 | using theme: Theme, 31 | content: [Path : String] = [:], 32 | additionalSteps: [PublishingStep] = [], 33 | plugins: [Plugin] = [], 34 | expectedHTML: [Path : String], 35 | allowWhitelistedOutputFiles: Bool = true, 36 | file: StaticString = #file, 37 | line: UInt = #line 38 | ) throws { 39 | let folder = try folder ?? Folder.createTemporary() 40 | 41 | let contentFolderName = "Content" 42 | try? folder.subfolder(named: contentFolderName).delete() 43 | 44 | let contentFolder = try folder.createSubfolder(named: contentFolderName) 45 | try addFiles(withContent: content, to: contentFolder, pathPrefix: "") 46 | 47 | try site.publish( 48 | withTheme: theme, 49 | at: Path(folder.path), 50 | rssFeedSections: [], 51 | additionalSteps: additionalSteps, 52 | plugins: plugins 53 | ) 54 | 55 | try verifyOutput( 56 | in: folder, 57 | expectedHTML: expectedHTML, 58 | allowWhitelistedFiles: allowWhitelistedOutputFiles, 59 | file: file, 60 | line: line 61 | ) 62 | } 63 | 64 | func publishWebsiteWithPodcast( 65 | in folder: Folder? = nil, 66 | using steps: [PublishingStep], 67 | content: [Path : String] = [:], 68 | file: StaticString = #file, 69 | line: UInt = #line 70 | ) throws { 71 | try performWebsitePublishing( 72 | in: folder, 73 | using: steps, 74 | files: content, 75 | filePathPrefix: "Content/" 76 | ) 77 | } 78 | 79 | func verifyOutput(in folder: Folder, 80 | expectedHTML: [Path : String], 81 | allowWhitelistedFiles: Bool = true, 82 | file: StaticString = #file, 83 | line: UInt = #line) throws { 84 | let outputFolder = try folder.subfolder(named: "Output") 85 | 86 | let whitelistedPaths: Set = [ 87 | "index.html", 88 | "one/index.html", 89 | "two/index.html", 90 | "three/index.html", 91 | "custom-raw-value/index.html", 92 | "tags/index.html" 93 | ] 94 | 95 | var expectedHTML = expectedHTML.mapValues { html in 96 | HTML(.body(.raw(html))).render() 97 | } 98 | 99 | for outputFile in outputFolder.files.recursive where outputFile.extension == "html" { 100 | let relativePath = Path(outputFile.path(relativeTo: outputFolder)) 101 | 102 | guard let html = expectedHTML.removeValue(forKey: relativePath) else { 103 | guard allowWhitelistedFiles, 104 | whitelistedPaths.contains(relativePath) else { 105 | return XCTFail( 106 | "Unexpected output file: \(relativePath)", 107 | file: file, 108 | line: line 109 | ) 110 | } 111 | 112 | continue 113 | } 114 | 115 | let outputHTML = try outputFile.readAsString() 116 | 117 | XCTAssert( 118 | outputHTML == html, 119 | "HTML mismatch. '\(outputHTML)' is not equal to '\(html)'.", 120 | file: file, 121 | line: line 122 | ) 123 | } 124 | 125 | let missingPaths = expectedHTML.keys.map { $0.string } 126 | 127 | XCTAssert( 128 | missingPaths.isEmpty, 129 | "Missing output files: \(missingPaths.joined(separator: ", "))", 130 | file: file, 131 | line: line 132 | ) 133 | } 134 | 135 | @discardableResult 136 | func publishWebsite( 137 | withItemMetadataType itemMetadataType: T.Type, 138 | using steps: [PublishingStep>], 139 | content: [Path : String] = [:] 140 | ) throws -> PublishedWebsite> { 141 | try performWebsitePublishing( 142 | using: steps, 143 | files: content, 144 | filePathPrefix: "Content/" 145 | ) 146 | } 147 | 148 | func generateItem( 149 | in section: WebsiteStub.SectionID = .one, 150 | fromMarkdown markdown: String, 151 | fileName: String = "markdown.md" 152 | ) throws -> Item { 153 | let site = try publishWebsite( 154 | using: [ 155 | .addMarkdownFiles() 156 | ], 157 | content: [ 158 | "\(section.rawValue)/\(fileName)" : markdown 159 | ] 160 | ) 161 | 162 | return try require(site.sections[section].items.first) 163 | } 164 | 165 | func generateItem( 166 | withMetadataType metadataType: T.Type, 167 | in section: WebsiteStub.SectionID = .one, 168 | fromMarkdown markdown: String, 169 | fileName: String = "markdown.md" 170 | ) throws -> Item> { 171 | let site = try publishWebsite( 172 | withItemMetadataType: T.self, 173 | using: [ 174 | .addMarkdownFiles() 175 | ], 176 | content: [ 177 | "\(section.rawValue)/\(fileName)" : markdown 178 | ] 179 | ) 180 | 181 | return try require(site.sections[section].items.first) 182 | } 183 | } 184 | 185 | private extension PublishTestCase { 186 | func addFiles(withContent fileContent: [Path : String], 187 | to folder: Folder, 188 | pathPrefix: String) throws { 189 | for (path, content) in fileContent { 190 | let path = pathPrefix + path.string 191 | try folder.createFile(at: path).write(content) 192 | } 193 | } 194 | 195 | @discardableResult 196 | func performWebsitePublishing( 197 | in folder: Folder? = nil, 198 | using steps: [PublishingStep], 199 | files: [Path : String], 200 | filePathPrefix: String = "" 201 | ) throws -> PublishedWebsite { 202 | let folder = try folder ?? Folder.createTemporary() 203 | 204 | try addFiles(withContent: files, to: folder, pathPrefix: filePathPrefix) 205 | 206 | return try T().publish( 207 | at: Path(folder.path), 208 | using: steps 209 | ) 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/Require.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | func require(_ expression: @autoclosure () -> T?, 10 | file: StaticString = #file, 11 | line: UInt = #line) throws -> T { 12 | guard let value = expression() else { 13 | throw RequireError(file: file, line: line) 14 | } 15 | 16 | return value 17 | } 18 | 19 | private struct RequireError: LocalizedError { 20 | let file: StaticString 21 | let line: UInt 22 | 23 | var errorDescription: String? { 24 | return "Required value of type \(Value.self) was nil at line \(line) in \(file)." 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/String+Unique.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | 9 | extension String { 10 | static func unique() -> String { 11 | UUID().uuidString 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/Stubbable.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Publish 9 | 10 | protocol Stubbable { 11 | static func stub(withPath path: Path) -> Self 12 | } 13 | 14 | extension Stubbable { 15 | static func stub() -> Self { 16 | stub(withPath: Path(.unique())) 17 | } 18 | 19 | func setting(_ keyPath: WritableKeyPath, 20 | to value: T) -> Self { 21 | var stub = self 22 | stub[keyPath: keyPath] = value 23 | return stub 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/PublishTests/Infrastructure/WebsiteStub.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import Foundation 8 | import Publish 9 | import Plot 10 | 11 | class WebsiteStub { 12 | enum SectionID: String, WebsiteSectionID { 13 | case one, two, three, customRawValue = "custom-raw-value" 14 | } 15 | 16 | var url = URL(string: "https://swiftbysundell.com")! 17 | var name = "WebsiteName" 18 | var description = "Description" 19 | var language = Language.english 20 | var imagePath: Path? = nil 21 | var faviconPath: Path? = nil 22 | var tagHTMLConfig: TagHTMLConfiguration? = .default 23 | 24 | required init() {} 25 | 26 | func title(for sectionID: WebsiteStub.SectionID) -> String { 27 | sectionID.rawValue 28 | } 29 | } 30 | 31 | extension WebsiteStub { 32 | final class WithItemMetadata: WebsiteStub, Website {} 33 | 34 | final class WithPodcastMetadata: WebsiteStub, Website { 35 | struct ItemMetadata: PodcastCompatibleWebsiteItemMetadata { 36 | var podcast: PodcastEpisodeMetadata? 37 | } 38 | } 39 | 40 | final class WithoutItemMetadata: WebsiteStub, Website { 41 | struct ItemMetadata: WebsiteItemMetadata {} 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/CLITests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import PublishCLICore 9 | import Files 10 | import ShellOut 11 | 12 | final class CLITests: PublishTestCase { 13 | func testWebsiteProjectGeneration() throws { 14 | #if INCLUDE_CLI 15 | let folder = try Folder.createTemporary() 16 | try makeCLI(in: folder, command: "new").run(in: folder) 17 | try makeCLI(in: folder, command: "generate").run(in: folder) 18 | #endif 19 | } 20 | 21 | func testPluginProjectGeneration() throws { 22 | #if INCLUDE_CLI 23 | let folder = try Folder.createTemporary(named: "Name") 24 | try makeCLI(in: folder, command: "new", "plugin").run(in: folder) 25 | 26 | XCTAssertTrue(folder.containsFile(at: "Sources/Name/Name.swift")) 27 | XCTAssertEqual(try folder.getPackageName(), "Name") 28 | 29 | // Make sure that the project can build 30 | try shellOut(to: "swift build", at: folder.path) 31 | #endif 32 | } 33 | 34 | func testSiteName() throws { 35 | #if INCLUDE_CLI 36 | let folder = try Folder.createTemporary(named: "Name") 37 | try makeCLI(in: folder, command: "new").run(in: folder) 38 | XCTAssertEqual(try folder.getPackageName(), "Name") 39 | #endif 40 | } 41 | 42 | func testSiteNameFromLowercasedFolderName() throws { 43 | #if INCLUDE_CLI 44 | let folder = try Folder.createTemporary(named: "name") 45 | try makeCLI(in: folder, command: "new").run(in: folder) 46 | XCTAssertEqual(try folder.getPackageName(), "Name") 47 | #endif 48 | } 49 | 50 | func testSiteNameFromFolderNameStartingWithDigit() throws { 51 | #if INCLUDE_CLI 52 | let folder = try Folder.createTemporary(named: "1-name") 53 | try makeCLI(in: folder, command: "new").run(in: folder) 54 | XCTAssertEqual(try folder.getPackageName(), "Name") 55 | #endif 56 | } 57 | 58 | func testSiteNameFromCamelCaseFolderName() throws { 59 | #if INCLUDE_CLI 60 | let folder = try Folder.createTemporary(named: "CamelCaseName") 61 | try makeCLI(in: folder, command: "new").run(in: folder) 62 | XCTAssertEqual(try folder.getPackageName(), "CamelCaseName") 63 | #endif 64 | } 65 | 66 | func testSiteNameWithNonLetterValidCharactersFolderName() throws { 67 | #if INCLUDE_CLI 68 | let folder = try Folder.createTemporary(named: "Blog.CamelCaseName2.com") 69 | try makeCLI(in: folder, command: "new").run(in: folder) 70 | XCTAssertEqual(try folder.getPackageName(), "BlogCamelCaseName2Com") 71 | #endif 72 | } 73 | 74 | func testSiteNameFromFolderNameWithNonLetters() throws { 75 | #if INCLUDE_CLI 76 | let folder = try Folder.createTemporary(named: "My website 1") 77 | try makeCLI(in: folder, command: "new").run(in: folder) 78 | XCTAssertEqual(try folder.getPackageName(), "MyWebsite") 79 | #endif 80 | } 81 | 82 | func testSiteNameFromDigitsOnlyFolderName() throws { 83 | #if INCLUDE_CLI 84 | let folder = try Folder.createTemporary(named: "1") 85 | try makeCLI(in: folder, command: "new").run(in: folder) 86 | let name = try folder.getPackageName() 87 | XCTAssertFalse(name.isEmpty) 88 | #endif 89 | } 90 | } 91 | 92 | private extension CLITests { 93 | func makeCLI(in folder: Folder, command: String...) throws -> CLI { 94 | let thisFile = try File(path: "\(#file)") 95 | let pathSuffix = "/Tests/PublishTests/Tests/CLITests.swift" 96 | 97 | let repositoryFolder = try Folder( 98 | path: String(thisFile.path.dropLast(pathSuffix.count)) 99 | ) 100 | 101 | return CLI( 102 | arguments: [folder.path] + command, 103 | publishRepositoryURL: URL( 104 | fileURLWithPath: repositoryFolder.path 105 | ), 106 | publishVersion: "0.1.0" 107 | ) 108 | } 109 | } 110 | 111 | private extension Folder { 112 | static func createTemporary(named: String) throws -> Self { 113 | let folder = try Folder.createTemporary() 114 | return try folder.createSubfolder(named: named) 115 | } 116 | 117 | func getPackageName() throws -> String { 118 | let sourcesFolder = try subfolder(named: "Sources") 119 | return try require(sourcesFolder.subfolders.first?.name) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/DeploymentTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Files 10 | import ShellOut 11 | 12 | final class DeploymentTests: PublishTestCase { 13 | private var defaultCommandLineArguments: [String]! 14 | 15 | override func setUp() { 16 | super.setUp() 17 | defaultCommandLineArguments = CommandLine.arguments 18 | } 19 | 20 | override func tearDown() { 21 | CommandLine.arguments = defaultCommandLineArguments 22 | super.tearDown() 23 | } 24 | 25 | func testDeploymentSkippedByDefault() throws { 26 | var deployed = false 27 | 28 | try publishWebsite(using: [ 29 | .step(named: "Custom") { _ in }, 30 | .deploy(using: DeploymentMethod(name: "Deploy") { _ in 31 | deployed = true 32 | }) 33 | ]) 34 | 35 | XCTAssertFalse(deployed) 36 | } 37 | 38 | func testGenerationStepsAndPluginsSkippedWhenDeploying() throws { 39 | CommandLine.arguments.append("--deploy") 40 | 41 | var generationPerformed = false 42 | var pluginInstalled = false 43 | 44 | try publishWebsite(using: [ 45 | .step(named: "Skipped") { _ in 46 | generationPerformed = true 47 | }, 48 | .installPlugin(Plugin(name: "Skipped") { _ in 49 | pluginInstalled = true 50 | }), 51 | .deploy(using: DeploymentMethod(name: "Deploy") { _ in }) 52 | ]) 53 | 54 | XCTAssertFalse(generationPerformed) 55 | XCTAssertFalse(pluginInstalled) 56 | } 57 | 58 | func testGitDeploymentMethod() throws { 59 | let container = try Folder.createTemporary() 60 | let remote = try container.createSubfolder(named: "Remote.git") 61 | let repo = try container.createSubfolder(named: "Repo") 62 | 63 | try shellOut(to: [ 64 | "git init", 65 | // Not all git installations init with a master branch. 66 | "git checkout master || git checkout -b master", 67 | "git config --local receive.denyCurrentBranch updateInstead" 68 | ], at: remote.path) 69 | 70 | // First generate 71 | try publishWebsite(in: repo, using: [ 72 | .generateHTML(withTheme: .foundation) 73 | ]) 74 | 75 | // Then deploy 76 | CommandLine.arguments.append("--deploy") 77 | 78 | try publishWebsite(in: repo, using: [ 79 | .deploy(using: .git(remote.path)) 80 | ]) 81 | 82 | let indexFile = try remote.file(named: "index.html") 83 | XCTAssertFalse(try indexFile.readAsString().isEmpty) 84 | } 85 | 86 | func testGitDeploymentMethodWithError() throws { 87 | let container = try Folder.createTemporary() 88 | let remote = try container.createSubfolder(named: "Remote.git") 89 | let repo = try container.createSubfolder(named: "Repo") 90 | 91 | try shellOut( 92 | to: [ 93 | "git init", 94 | // Not all git installations init with a master branch. 95 | "git checkout master || git checkout -b master" 96 | ], 97 | at: remote.path 98 | ) 99 | 100 | // First generate 101 | try publishWebsite(in: repo, using: [ 102 | .generateHTML(withTheme: .foundation) 103 | ]) 104 | 105 | // Then deploy 106 | CommandLine.arguments.append("--deploy") 107 | 108 | var thrownError: PublishingError? 109 | 110 | do { 111 | try publishWebsite( 112 | in: repo, 113 | using: [.deploy(using: .git(remote.path))] 114 | ) 115 | } catch { 116 | thrownError = error as? PublishingError 117 | } 118 | 119 | // We don't want to make too many assumptions about the way 120 | // Git phrases its error messages here, so we just perform 121 | // a few basic checks to make sure we have some form of output: 122 | let infoMessage = try require(thrownError?.infoMessage) 123 | XCTAssertTrue(infoMessage.contains("receive.denyCurrentBranch")) 124 | XCTAssertTrue(infoMessage.contains("[remote rejected]")) 125 | } 126 | 127 | func testDeployingUsingCustomOutputFolder() throws { 128 | let container = try Folder.createTemporary() 129 | 130 | // First generate 131 | try publishWebsite(in: container, using: [ 132 | .addMarkdownFiles(), 133 | .generateHTML(withTheme: .foundation) 134 | ], content: [ 135 | "one/a.md": "Text" 136 | ]) 137 | 138 | // Then deploy 139 | CommandLine.arguments.append("--deploy") 140 | 141 | var outputFolder: Folder? 142 | 143 | try publishWebsite(in: container, using: [ 144 | .deploy(using: DeploymentMethod(name: "Test") { context in 145 | outputFolder = try context.createDeploymentFolder( 146 | withPrefix: "Test", 147 | outputFolderPath: "CustomOutput", 148 | configure: { _ in } 149 | ) 150 | }) 151 | ]) 152 | 153 | let folder = try require(outputFolder) 154 | let subfolder = try folder.subfolder(named: "CustomOutput") 155 | XCTAssertTrue(subfolder.containsSubfolder(at: "one/a")) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/ErrorTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | 10 | final class ErrorTests: PublishTestCase { 11 | func testErrorForInvalidRootPath() throws { 12 | assertErrorThrown( 13 | try WebsiteStub.WithoutItemMetadata().publish( 14 | at: "🤷‍♂️", 15 | using: [] 16 | ), 17 | PublishingError( 18 | path: "🤷‍♂️", 19 | infoMessage: "Could not find the requested root folder" 20 | ) 21 | ) 22 | } 23 | 24 | func testErrorForMissingMarkdownMetadata() throws { 25 | struct Metadata: WebsiteItemMetadata { 26 | let string: String 27 | } 28 | 29 | let markdown = """ 30 | --- 31 | title: Hello 32 | --- 33 | """ 34 | 35 | assertErrorThrown( 36 | try generateItem( 37 | withMetadataType: Metadata.self, 38 | in: .one, 39 | fromMarkdown: markdown, 40 | fileName: "file.md" 41 | ), 42 | PublishingError( 43 | stepName: "Add Markdown files from 'Content' folder", 44 | path: "one/file.md", 45 | infoMessage: "Missing metadata value for key 'string'" 46 | ) 47 | ) 48 | } 49 | 50 | func testErrorForInvalidMarkdownMetadata() throws { 51 | let markdown = """ 52 | --- 53 | audio.url: 🤷‍♂️ 54 | --- 55 | """ 56 | 57 | assertErrorThrown( 58 | try generateItem( 59 | in: .one, 60 | fromMarkdown: markdown, 61 | fileName: "file.md" 62 | ), 63 | PublishingError( 64 | stepName: "Add Markdown files from 'Content' folder", 65 | path: "one/file.md", 66 | infoMessage: "Invalid metadata value for key 'audio.url'" 67 | ) 68 | ) 69 | } 70 | 71 | func testErrorForThrowingDuringItemMutation() throws { 72 | struct Error: LocalizedError { 73 | var errorDescription: String? { "An error" } 74 | } 75 | 76 | assertErrorThrown( 77 | try publishWebsite(using: [ 78 | .addItem(.stub(withPath: "path/to/item")), 79 | .mutateAllItems { _ in 80 | throw Error() 81 | } 82 | ]), 83 | PublishingError( 84 | stepName: "Mutate all items", 85 | path: "one/path/to/item", 86 | infoMessage: "Item mutation failed", 87 | underlyingError: Error() 88 | ) 89 | ) 90 | } 91 | 92 | func testErrorForMissingPage() throws { 93 | assertErrorThrown( 94 | try publishWebsite(using: [ 95 | .mutatePage(at: "invalid/path") { _ in } 96 | ]), 97 | PublishingError( 98 | stepName: "Mutate page at 'invalid/path'", 99 | path: "invalid/path", 100 | infoMessage: "Page not found" 101 | ) 102 | ) 103 | } 104 | 105 | func testErrorForThrowingDuringPageMutation() throws { 106 | struct Error: LocalizedError { 107 | var errorDescription: String? { "An error" } 108 | } 109 | 110 | assertErrorThrown( 111 | try publishWebsite(using: [ 112 | .addPage(.stub(withPath: "page")), 113 | .mutateAllPages { _ in 114 | throw Error() 115 | } 116 | ]), 117 | PublishingError( 118 | stepName: "Mutate all pages", 119 | path: "page", 120 | infoMessage: "Page mutation failed", 121 | underlyingError: Error() 122 | ) 123 | ) 124 | } 125 | 126 | func testErrorForMissingFolder() throws { 127 | assertErrorThrown( 128 | try publishWebsite(using: [ 129 | .copyFiles(at: "non/existing") 130 | ]), 131 | PublishingError( 132 | stepName: "Copy 'non/existing' files", 133 | path: "non/existing", 134 | infoMessage: "Folder not found" 135 | ) 136 | ) 137 | } 138 | 139 | func testErrorForMissingFile() throws { 140 | assertErrorThrown( 141 | try publishWebsite(using: [ 142 | .copyFile(at: "non/existing.png") 143 | ]), 144 | PublishingError( 145 | stepName: "Copy file 'non/existing.png'", 146 | path: "non/existing.png", 147 | infoMessage: "File not found" 148 | ) 149 | ) 150 | } 151 | 152 | func testErrorForNoPublishingSteps() throws { 153 | assertErrorThrown( 154 | try publishWebsite(using: []), 155 | PublishingError( 156 | infoMessage: "WebsiteName has no generation steps." 157 | ) 158 | ) 159 | 160 | CommandLine.arguments.append("--deploy") 161 | 162 | assertErrorThrown( 163 | try publishWebsite(using: []), 164 | PublishingError( 165 | infoMessage: "WebsiteName has no deployment steps." 166 | ) 167 | ) 168 | 169 | CommandLine.arguments.removeLast() 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/FileIOTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Files 10 | 11 | final class FileIOTests: PublishTestCase { 12 | func testCopyingFile() throws { 13 | let folder = try Folder.createTemporary() 14 | try folder.createFile(named: "File").write("Hello, world!") 15 | 16 | try publishWebsite(in: folder, using: [ 17 | .copyFile(at: "File") 18 | ]) 19 | 20 | let file = try folder.file(at: "Output/File") 21 | XCTAssertEqual(try file.readAsString(), "Hello, world!") 22 | } 23 | 24 | func testCopyingFileToSpecificFolder() throws { 25 | let folder = try Folder.createTemporary() 26 | try folder.createFile(named: "File").write("Hello, world!") 27 | 28 | try publishWebsite(in: folder, using: [ 29 | .copyFile(at: "File", to: "Custom/Path") 30 | ]) 31 | 32 | let file = try folder.file(at: "Output/Custom/Path/File") 33 | XCTAssertEqual(try file.readAsString(), "Hello, world!") 34 | } 35 | 36 | func testCopyingFolder() throws { 37 | let folder = try Folder.createTemporary() 38 | try folder.createSubfolder(named: "Subfolder") 39 | 40 | try publishWebsite(in: folder, using: [ 41 | .step(named: "Copy custom folder") { context in 42 | try context.copyFolderToOutput(from: "Subfolder") 43 | } 44 | ]) 45 | 46 | XCTAssertNotNil(try? folder.subfolder(at: "Output/Subfolder")) 47 | } 48 | 49 | func testCopyingResourcesWithFolder() throws { 50 | let folder = try Folder.createTemporary() 51 | let resourcesFolder = try folder.createSubfolder(named: "Resources") 52 | try resourcesFolder.createFile(named: "File").write("Hello") 53 | let nestedFolder = try resourcesFolder.createSubfolder(named: "Subfolder") 54 | try nestedFolder.createFile(named: "Nested").write("World!") 55 | 56 | try publishWebsite(in: folder, using: [ 57 | .copyResources(includingFolder: true) 58 | ]) 59 | 60 | let rootFile = try folder.file(at: "Output/Resources/File") 61 | let nestedFile = try folder.file(at: "Output/Resources/Subfolder/Nested") 62 | XCTAssertEqual(try rootFile.readAsString(), "Hello") 63 | XCTAssertEqual(try nestedFile.readAsString(), "World!") 64 | } 65 | 66 | func testCopyingResourcesWithoutFolder() throws { 67 | let folder = try Folder.createTemporary() 68 | let resourcesFolder = try folder.createSubfolder(named: "Resources") 69 | try resourcesFolder.createFile(named: "File").write("Hello") 70 | let nestedFolder = try resourcesFolder.createSubfolder(named: "Subfolder") 71 | try nestedFolder.createFile(named: "Nested").write("World!") 72 | 73 | try publishWebsite(in: folder, using: [ 74 | .copyResources() 75 | ]) 76 | 77 | let rootFile = try folder.file(at: "Output/File") 78 | let nestedFile = try folder.file(at: "Output/Subfolder/Nested") 79 | XCTAssertEqual(try rootFile.readAsString(), "Hello") 80 | XCTAssertEqual(try nestedFile.readAsString(), "World!") 81 | } 82 | 83 | func testCreatingRootLevelFolder() throws { 84 | let folder = try Folder.createTemporary() 85 | 86 | try publishWebsite(in: folder, using: [ 87 | .step(named: "Create folder") { context in 88 | _ = try context.createFolder(at: "A") 89 | _ = try context.createFile(at: "B/file") 90 | } 91 | ]) 92 | 93 | XCTAssertNotNil(try? folder.subfolder(named: "A")) 94 | XCTAssertNotNil(try? folder.file(at: "B/file")) 95 | } 96 | 97 | func testRetrievingOutputFolder() throws { 98 | let folder = try Folder.createTemporary() 99 | var firstSectionFolder: Folder? 100 | 101 | try publishWebsite(in: folder, using: [ 102 | .generateHTML(withTheme: .foundation), 103 | .step(named: "Get output folder") { context in 104 | firstSectionFolder = try context.outputFolder(at: "one") 105 | } 106 | ]) 107 | 108 | XCTAssertEqual(firstSectionFolder?.name, "one") 109 | } 110 | 111 | func testRetrievingOutputFile() throws { 112 | let folder = try Folder.createTemporary() 113 | var itemFile: File? 114 | 115 | try publishWebsite(in: folder, using: [ 116 | .addItem(.stub(withPath: "item")), 117 | .generateHTML(withTheme: .foundation), 118 | .step(named: "Get output file") { context in 119 | itemFile = try context.outputFile(at: "one/item/index.html") 120 | } 121 | ]) 122 | 123 | XCTAssertEqual(itemFile?.name, "index.html") 124 | } 125 | 126 | func testCleaningHiddenFilesInOutputFolder() throws { 127 | let folder = try Folder.createTemporary() 128 | try folder.createFile(at: "Output/.hidden") 129 | 130 | try publishWebsite(in: folder, using: [ 131 | .step(named: "Do nothing") { _ in } 132 | ]) 133 | 134 | XCTAssertFalse(folder.containsFile(named: "Output/.hidden")) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/MarkdownTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Files 9 | import Ink 10 | import Publish 11 | 12 | final class MarkdownTests: PublishTestCase { 13 | func testParsingFileWithTitle() throws { 14 | let item = try generateItem(fromMarkdown: "# Title") 15 | XCTAssertEqual(item.title, "Title") 16 | } 17 | 18 | func testParsingFileWithOverriddenTitle() throws { 19 | let item = try generateItem(fromMarkdown: """ 20 | --- 21 | title: Overridden title 22 | --- 23 | # Title 24 | """) 25 | 26 | XCTAssertEqual(item.title, "Overridden title") 27 | } 28 | 29 | func testParsingFileWithNoTitle() throws { 30 | let item = try generateItem(fromMarkdown: """ 31 | --- 32 | description: A description 33 | --- 34 | No title here 35 | """, fileName: "fallback.md") 36 | 37 | XCTAssertEqual(item.title, "fallback") 38 | } 39 | 40 | func testParsingFileWithOverriddenPath() throws { 41 | let item = try generateItem(fromMarkdown: """ 42 | --- 43 | path: overridden-path 44 | --- 45 | """) 46 | 47 | XCTAssertEqual(item.path, "one/overridden-path") 48 | } 49 | 50 | func testParsingFileWithBuiltInMetadata() throws { 51 | let item = try generateItem(fromMarkdown: """ 52 | --- 53 | description: Description 54 | tags: One, Two, Three 55 | image: myImage.png 56 | date: 2019-12-14 10:30 57 | audio.url: https://myFile.mp3 58 | audio.duration: 01:03:05 59 | video.youTube: 12345 60 | --- 61 | """) 62 | 63 | var expectedDateComponents = DateComponents() 64 | expectedDateComponents.calendar = .autoupdatingCurrent 65 | expectedDateComponents.year = 2019 66 | expectedDateComponents.month = 12 67 | expectedDateComponents.day = 14 68 | expectedDateComponents.hour = 10 69 | expectedDateComponents.minute = 30 70 | 71 | XCTAssertEqual(item.description, "Description") 72 | XCTAssertEqual(item.tags, ["One", "Two", "Three"]) 73 | XCTAssertEqual(item.imagePath, "myImage.png") 74 | XCTAssertEqual(item.date, expectedDateComponents.date) 75 | XCTAssertEqual(item.audio?.url, URL(string: "https://myFile.mp3")) 76 | XCTAssertEqual(item.audio?.duration, Audio.Duration(hours: 1, minutes: 3, seconds: 5)) 77 | XCTAssertEqual(item.video, .youTube(id: "12345")) 78 | } 79 | 80 | func testParsingFileWithCustomMetadata() throws { 81 | struct Metadata: WebsiteItemMetadata { 82 | struct Nested: WebsiteItemMetadata { 83 | var string: String 84 | var url: URL 85 | } 86 | 87 | var string: String 88 | var url: URL 89 | var int: Int 90 | var double: Double 91 | var stringArray: [String] 92 | var urlArray: [URL] 93 | var intArray: [Int] 94 | var nested: Nested 95 | } 96 | 97 | let item = try generateItem( 98 | withMetadataType: Metadata.self, 99 | fromMarkdown: """ 100 | --- 101 | string: Hello, world! 102 | url: https://url.com 103 | int: 42 104 | double: 3.14 105 | stringArray: One, Two, Three 106 | urlArray: https://a.url, https://b.url 107 | intArray: 1, 2, 3 108 | nested.string: I'm nested! 109 | nested.url: https://nested.url 110 | --- 111 | """ 112 | ) 113 | 114 | let expectedURLs = ["https://a.url", "https://b.url"].compactMap(URL.init) 115 | 116 | XCTAssertEqual(item.metadata.string, "Hello, world!") 117 | XCTAssertEqual(item.metadata.url, URL(string: "https://url.com")) 118 | XCTAssertEqual(item.metadata.int, 42) 119 | XCTAssertEqual(item.metadata.double, 3.14) 120 | XCTAssertEqual(item.metadata.stringArray, ["One", "Two", "Three"]) 121 | XCTAssertEqual(item.metadata.urlArray, expectedURLs) 122 | XCTAssertEqual(item.metadata.intArray, [1, 2, 3]) 123 | XCTAssertEqual(item.metadata.nested.string, "I'm nested!") 124 | XCTAssertEqual(item.metadata.nested.url, URL(string: "https://nested.url")) 125 | } 126 | 127 | func testParsingPageInNestedFolder() throws { 128 | let folder = try Folder.createTemporary() 129 | let pageFile = try folder.createFile(at: "Content/my/custom/page.md") 130 | try pageFile.write("# MyPage") 131 | 132 | let site = try publishWebsite(in: folder, using: [ 133 | .addMarkdownFiles() 134 | ]) 135 | 136 | XCTAssertEqual(site.pages["my/custom/page"]?.title, "MyPage") 137 | } 138 | 139 | func testNotParsingNonMarkdownFiles() throws { 140 | let folder = try Folder.createTemporary() 141 | try folder.createFile(at: "Content/image.png") 142 | try folder.createFile(at: "Content/one/image.png") 143 | try folder.createFile(at: "Content/custom/image.png") 144 | 145 | let site = try publishWebsite(in: folder, using: [ 146 | .addMarkdownFiles() 147 | ]) 148 | 149 | XCTAssertEqual(site.pages, [:]) 150 | XCTAssertEqual(site.sections[.one].items, []) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/PathTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Codextended 10 | 11 | final class PathTests: PublishTestCase { 12 | func testAbsoluteString() { 13 | XCTAssertEqual(Path("relative").absoluteString, "/relative") 14 | XCTAssertEqual(Path("/absolute").absoluteString, "/absolute") 15 | } 16 | 17 | func testAppendingComponent() { 18 | let path = Path("one") 19 | XCTAssertEqual(path.appendingComponent("two"), "one/two") 20 | } 21 | 22 | func testStringInterpolation() { 23 | let path = Path("my/path") 24 | XCTAssertEqual("\(path)", "my/path") 25 | } 26 | 27 | func testCoding() throws { 28 | struct Wrapper: Equatable, Codable { 29 | let path: Path 30 | } 31 | 32 | let wrapper = Wrapper(path: Path("my/path")) 33 | let data = try wrapper.encoded() 34 | XCTAssertEqual(wrapper, try data.decoded()) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/PlotComponentTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Plot 10 | import Ink 11 | 12 | final class PlotComponentTests: PublishTestCase { 13 | func testStylesheetPaths() { 14 | let html = Node.head( 15 | for: Page(path: "path", content: Content()), 16 | on: WebsiteStub.WithoutItemMetadata(), 17 | stylesheetPaths: [ 18 | "local-1.css", 19 | "/local-2.css", 20 | "http://external-1.css", 21 | "https://external-2.css" 22 | ] 23 | ).render() 24 | 25 | let expectedURLs = [ 26 | "/local-1.css", 27 | "/local-2.css", 28 | "http://external-1.css", 29 | "https://external-2.css" 30 | ] 31 | 32 | for url in expectedURLs { 33 | XCTAssertTrue(html.contains(""" 34 | 35 | """)) 36 | } 37 | } 38 | 39 | func testRenderingAudioPlayer() throws { 40 | let url = try require(URL(string: "https://audio.mp3")) 41 | let audio = Audio(url: url, format: .mp3) 42 | let html = Node.audioPlayer(for: audio).render() 43 | 44 | XCTAssertEqual(html, """ 45 | 46 | """) 47 | } 48 | 49 | func testRenderingHostedVideoPlayer() throws { 50 | let url = try require(URL(string: "https://video.mp4")) 51 | let video = Video.hosted(url: url, format: .mp4) 52 | let html = Node.videoPlayer(for: video).render() 53 | 54 | XCTAssertEqual(html, """ 55 | 56 | """) 57 | } 58 | 59 | func testRenderingYouTubeVideoPlayer() { 60 | let video = Video.youTube(id: "123") 61 | let html = Node.videoPlayer(for: video).render() 62 | 63 | XCTAssertEqual(html, """ 64 | 69 | """) 70 | } 71 | 72 | func testRenderingVimeoVideoPlayer() { 73 | let video = Video.vimeo(id: "123") 74 | let html = Node.videoPlayer(for: video).render() 75 | 76 | XCTAssertEqual(html, """ 77 | 82 | """) 83 | } 84 | 85 | func testRenderingMarkdownComponent() { 86 | let customParser = MarkdownParser(modifiers: [ 87 | Modifier(target: .links) { html, _ in 88 | return "\(html)" 89 | } 90 | ]) 91 | 92 | let html = Div { 93 | Markdown("[First](/first)") 94 | Div { 95 | Markdown("[Second](/second)") 96 | } 97 | .markdownParser(customParser) 98 | } 99 | .render() 100 | 101 | XCTAssertEqual(html, """ 102 |
\ 103 |

First

\ 104 | \ 105 |
106 | """) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/PluginTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Plot 10 | import Ink 11 | 12 | final class PluginTests: PublishTestCase { 13 | func testAddingContentUsingPlugin() throws { 14 | let site = try publishWebsite(using: [ 15 | .installPlugin(Plugin(name: "Plugin") { context in 16 | context.addItem(.stub()) 17 | }) 18 | ]) 19 | 20 | XCTAssertEqual(site.sections[.one].items.count, 1) 21 | } 22 | 23 | func testAddingInkModifierUsingPlugin() throws { 24 | let site = try publishWebsite(using: [ 25 | .installPlugin(Plugin(name: "Plugin") { context in 26 | context.markdownParser.addModifier(Modifier( 27 | target: .paragraphs, 28 | closure: { html, _ in 29 | "
\(html)
" 30 | } 31 | )) 32 | }), 33 | .addMarkdownFiles() 34 | ], content: [ 35 | "one/a.md": "Hello" 36 | ]) 37 | 38 | let items = site.sections[.one].items 39 | XCTAssertEqual(items.count, 1) 40 | XCTAssertEqual(items.first?.path, "one/a") 41 | XCTAssertEqual(items.first?.body.html, "

Hello

") 42 | } 43 | 44 | func testAddingPluginToDefaultPipeline() throws { 45 | let htmlFactory = HTMLFactoryMock() 46 | htmlFactory.makeIndexHTML = { content, _ in 47 | HTML(.body(content.body.node)) 48 | } 49 | 50 | try publishWebsite( 51 | using: Theme(htmlFactory: htmlFactory), 52 | content: ["index.md": "Hello, World!"], 53 | plugins: [Plugin(name: "Plugin") { context in 54 | context.markdownParser.addModifier(Modifier( 55 | target: .paragraphs, 56 | closure: { html, _ in 57 | "
\(html)
" 58 | } 59 | )) 60 | }], 61 | expectedHTML: ["index.html": "

Hello, World!

"] 62 | ) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/PodcastFeedGenerationTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Files 10 | 11 | final class PodcastFeedGenerationTests: PublishTestCase { 12 | func testOnlyIncludingSpecifiedSection() throws { 13 | let folder = try Folder.createTemporary() 14 | 15 | try generateFeed(in: folder, content: [ 16 | "one/a.md": """ 17 | \(makeStubbedAudioMetadata()) 18 | # Included 19 | """, 20 | "two/b": "# Not included" 21 | ]) 22 | 23 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 24 | XCTAssertTrue(feed.contains("Included")) 25 | XCTAssertFalse(feed.contains("Not included")) 26 | } 27 | 28 | func testOnlyIncludingItemsMatchingPredicate() throws { 29 | let folder = try Folder.createTemporary() 30 | 31 | try generateFeed( 32 | in: folder, 33 | itemPredicate: \.path == "one/a", 34 | content: [ 35 | "one/a.md": """ 36 | \(makeStubbedAudioMetadata()) 37 | # Included 38 | """, 39 | "one/b.md": "# Not included" 40 | ] 41 | ) 42 | 43 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 44 | XCTAssertTrue(feed.contains("Included")) 45 | XCTAssertFalse(feed.contains("Not included")) 46 | } 47 | 48 | func testConvertingRelativeLinksToAbsolute() throws { 49 | let folder = try Folder.createTemporary() 50 | 51 | try generateFeed(in: folder, content: [ 52 | "one/item.md": """ 53 | \(makeStubbedAudioMetadata()) 54 | BEGIN [Link](/page) ![Image](/image.png) [Link](https://apple.com) END 55 | """ 56 | ]) 57 | 58 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 59 | let substring = feed.substrings(between: "BEGIN ", and: " END").first 60 | 61 | XCTAssertEqual(substring, """ 62 | Link \ 63 | \"Image\"/ \ 64 | Link 65 | """) 66 | } 67 | 68 | func testItemPrefixAndSuffix() throws { 69 | let folder = try Folder.createTemporary() 70 | 71 | let prefixSuffix = """ 72 | rss.titlePrefix: Prefix 73 | rss.titleSuffix: Suffix 74 | """ 75 | 76 | try generateFeed(in: folder, content: [ 77 | "one/item.md": """ 78 | \(makeStubbedAudioMetadata(including: prefixSuffix)) 79 | # Title 80 | """ 81 | ]) 82 | 83 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 84 | XCTAssertTrue(feed.contains("PrefixTitleSuffix")) 85 | } 86 | 87 | func testReusingPreviousFeedIfNoItemsWereModified() throws { 88 | let folder = try Folder.createTemporary() 89 | let contentFile = try folder.createFile(at: "Content/one/item.md") 90 | try contentFile.write(makeStubbedAudioMetadata()) 91 | 92 | try generateFeed(in: folder) 93 | let feedA = try folder.file(at: "Output/feed.rss").readAsString() 94 | 95 | let newDate = Date().addingTimeInterval(60 * 60) 96 | try generateFeed(in: folder, date: newDate) 97 | let feedB = try folder.file(at: "Output/feed.rss").readAsString() 98 | 99 | XCTAssertEqual(feedA, feedB) 100 | 101 | try contentFile.append("New content") 102 | try generateFeed(in: folder, date: newDate) 103 | let feedC = try folder.file(at: "Output/feed.rss").readAsString() 104 | 105 | XCTAssertNotEqual(feedB, feedC) 106 | } 107 | 108 | func testNotReusingPreviousFeedIfConfigChanged() throws { 109 | let folder = try Folder.createTemporary() 110 | let contentFile = try folder.createFile(at: "Content/one/item.md") 111 | try contentFile.write(makeStubbedAudioMetadata()) 112 | 113 | try generateFeed(in: folder) 114 | let feedA = try folder.file(at: "Output/feed.rss").readAsString() 115 | 116 | var newConfig = try makeConfigStub() 117 | newConfig.author.name = "New author name" 118 | let newDate = Date().addingTimeInterval(60 * 60) 119 | try generateFeed(in: folder, config: newConfig, date: newDate) 120 | let feedB = try folder.file(at: "Output/feed.rss").readAsString() 121 | 122 | XCTAssertNotEqual(feedA, feedB) 123 | } 124 | 125 | func testNotReusingPreviousFeedIfItemWasAdded() throws { 126 | let folder = try Folder.createTemporary() 127 | 128 | let audio = try Audio( 129 | url: require(URL(string: "https://audio.mp3")), 130 | duration: Audio.Duration(), 131 | byteSize: 55 132 | ) 133 | 134 | let itemA = Item( 135 | path: "a", 136 | sectionID: .one, 137 | metadata: .init(podcast: .init()), 138 | content: Content(audio: audio) 139 | ) 140 | 141 | let itemB = Item( 142 | path: "b", 143 | sectionID: .one, 144 | metadata: .init(podcast: .init()), 145 | content: Content( 146 | lastModified: itemA.lastModified, 147 | audio: audio 148 | ) 149 | ) 150 | 151 | try generateFeed(in: folder, generationSteps: [ 152 | .addItem(itemA) 153 | ]) 154 | 155 | let feedA = try folder.file(at: "Output/feed.rss").readAsString() 156 | 157 | try generateFeed(in: folder, generationSteps: [ 158 | .addItem(itemA), 159 | .addItem(itemB) 160 | ]) 161 | 162 | let feedB = try folder.file(at: "Output/feed.rss").readAsString() 163 | XCTAssertNotEqual(feedA, feedB) 164 | } 165 | } 166 | 167 | private extension PodcastFeedGenerationTests { 168 | typealias Site = WebsiteStub.WithPodcastMetadata 169 | typealias Configuration = PodcastFeedConfiguration 170 | 171 | func makeConfigStub() throws -> Configuration { 172 | try Configuration( 173 | targetPath: .defaultForRSSFeed, 174 | imageURL: require(URL(string: "image.png")), 175 | copyrightText: "John Appleseed 2019", 176 | author: PodcastAuthor( 177 | name: "John Appleseed", 178 | emailAddress: "john.appleseed@apple.com" 179 | ), 180 | description: "Description", 181 | subtitle: "Subtitle", 182 | category: "Category" 183 | ) 184 | } 185 | 186 | func makeStubbedAudioMetadata(including additionalString: String = "") -> String { 187 | """ 188 | --- 189 | audio.url: https://audio.mp3 190 | audio.duration: 05:02 191 | audio.size: 12345 192 | \(additionalString) 193 | --- 194 | """ 195 | } 196 | 197 | func generateFeed( 198 | in folder: Folder, 199 | config: Configuration? = nil, 200 | itemPredicate: Predicate>? = nil, 201 | generationSteps: [PublishingStep] = [ 202 | .addMarkdownFiles() 203 | ], 204 | date: Date = Date(), 205 | content: [Path : String] = [:] 206 | ) throws { 207 | try publishWebsiteWithPodcast(in: folder, using: [ 208 | .group(generationSteps), 209 | .generatePodcastFeed( 210 | for: .one, 211 | itemPredicate: itemPredicate, 212 | config: config ?? makeConfigStub(), 213 | date: date 214 | ) 215 | ], content: content) 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/PublishingContextTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2020 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | 10 | final class PublishingContextTests: PublishTestCase { 11 | func testSectionIterationOrder() throws { 12 | let expectedOrder = WebsiteStub.SectionID.allCases 13 | var actualOrder = [WebsiteStub.SectionID]() 14 | 15 | try publishWebsite(using: [ 16 | .step(named: "Step") { context in 17 | context.sections.forEach { section in 18 | actualOrder.append(section.id) 19 | } 20 | } 21 | ]) 22 | 23 | XCTAssertEqual(expectedOrder, actualOrder) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/RSSFeedGenerationTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Files 10 | import Sweep 11 | 12 | final class RSSFeedGenerationTests: PublishTestCase { 13 | func testOnlyIncludingSpecifiedSections() throws { 14 | let folder = try Folder.createTemporary() 15 | 16 | try generateFeed(in: folder, content: [ 17 | "one/a.md": "Included", 18 | "two/b.md": "Not included" 19 | ]) 20 | 21 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 22 | XCTAssertTrue(feed.contains("Included")) 23 | XCTAssertFalse(feed.contains("Not included")) 24 | } 25 | 26 | func testOnlyIncludingItemsMatchingPredicate() throws { 27 | let folder = try Folder.createTemporary() 28 | 29 | try generateFeed( 30 | in: folder, 31 | itemPredicate: \.path == "one/a", 32 | content: [ 33 | "one/a.md": "Included", 34 | "one/b.md": "Not included" 35 | ] 36 | ) 37 | 38 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 39 | XCTAssertTrue(feed.contains("Included")) 40 | XCTAssertFalse(feed.contains("Not included")) 41 | } 42 | 43 | func testConvertingRelativeLinksToAbsolute() throws { 44 | let folder = try Folder.createTemporary() 45 | 46 | try generateFeed(in: folder, content: [ 47 | "one/item.md": """ 48 | BEGIN [Link](/page) ![Image](/image.png) [Link](https://apple.com) END 49 | """ 50 | ]) 51 | 52 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 53 | let substring = feed.firstSubstring(between: "BEGIN ", and: " END") 54 | 55 | XCTAssertEqual(substring, """ 56 | Link \ 57 | \"Image\"/ \ 58 | Link 59 | """) 60 | } 61 | 62 | func testItemTitlePrefixAndSuffix() throws { 63 | let folder = try Folder.createTemporary() 64 | 65 | try generateFeed(in: folder, content: [ 66 | "one/item.md": """ 67 | --- 68 | rss.titlePrefix: Prefix 69 | rss.titleSuffix: Suffix 70 | --- 71 | # Title 72 | """ 73 | ]) 74 | 75 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 76 | XCTAssertTrue(feed.contains("PrefixTitleSuffix")) 77 | } 78 | 79 | func testItemBodyPrefixAndSuffix() throws { 80 | let folder = try Folder.createTemporary() 81 | 82 | try generateFeed(in: folder, content: [ 83 | "one/item.md": """ 84 | --- 85 | rss.bodyPrefix: Prefix 86 | rss.bodySuffix: Suffix 87 | --- 88 | Body 89 | """ 90 | ]) 91 | 92 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 93 | 94 | XCTAssertTrue(feed.contains(""" 95 | Body

Suffix]]>
96 | """)) 97 | } 98 | 99 | func testCustomItemLink() throws { 100 | let folder = try Folder.createTemporary() 101 | 102 | try generateFeed(in: folder, content: [ 103 | "one/item.md": """ 104 | --- 105 | rss.link: custom.link 106 | --- 107 | Body 108 | """ 109 | ]) 110 | 111 | let feed = try folder.file(at: "Output/feed.rss").readAsString() 112 | 113 | XCTAssertTrue(feed.contains("custom.link")) 114 | 115 | XCTAssertTrue(feed.contains(""" 116 | https://swiftbysundell.com/one/item 117 | """)) 118 | } 119 | 120 | func testReusingPreviousFeedIfNoItemsWereModified() throws { 121 | let folder = try Folder.createTemporary() 122 | let contentFile = try folder.createFile(at: "Content/one/item.md") 123 | 124 | try generateFeed(in: folder) 125 | let feedA = try folder.file(at: "Output/feed.rss").readAsString() 126 | 127 | let newDate = Date().addingTimeInterval(60 * 60) 128 | try generateFeed(in: folder, date: newDate) 129 | let feedB = try folder.file(at: "Output/feed.rss").readAsString() 130 | 131 | XCTAssertEqual(feedA, feedB) 132 | 133 | try contentFile.append("New content") 134 | try generateFeed(in: folder, date: newDate) 135 | let feedC = try folder.file(at: "Output/feed.rss").readAsString() 136 | 137 | XCTAssertNotEqual(feedB, feedC) 138 | } 139 | 140 | func testNotReusingPreviousFeedIfConfigChanged() throws { 141 | let folder = try Folder.createTemporary() 142 | try folder.createFile(at: "Content/one/item.md") 143 | 144 | try generateFeed(in: folder) 145 | let feedA = try folder.file(at: "Output/feed.rss").readAsString() 146 | 147 | let newConfig = RSSFeedConfiguration(ttlInterval: 5000) 148 | let newDate = Date().addingTimeInterval(60 * 60) 149 | try generateFeed(in: folder, config: newConfig, date: newDate) 150 | let feedB = try folder.file(at: "Output/feed.rss").readAsString() 151 | 152 | XCTAssertNotEqual(feedA, feedB) 153 | } 154 | 155 | func testNotReusingPreviousFeedIfItemWasAdded() throws { 156 | let folder = try Folder.createTemporary() 157 | let itemA = Item.stub() 158 | let itemB = Item.stub().setting(\.lastModified, to: itemA.lastModified) 159 | 160 | try generateFeed(in: folder, generationSteps: [ 161 | .addItem(itemA) 162 | ]) 163 | 164 | let feedA = try folder.file(at: "Output/feed.rss").readAsString() 165 | 166 | try generateFeed(in: folder, generationSteps: [ 167 | .addItem(itemA), 168 | .addItem(itemB) 169 | ]) 170 | 171 | let feedB = try folder.file(at: "Output/feed.rss").readAsString() 172 | XCTAssertNotEqual(feedA, feedB) 173 | } 174 | } 175 | 176 | private extension RSSFeedGenerationTests { 177 | typealias Site = WebsiteStub.WithoutItemMetadata 178 | 179 | func generateFeed( 180 | in folder: Folder, 181 | config: RSSFeedConfiguration = .default, 182 | itemPredicate: Predicate>? = nil, 183 | generationSteps: [PublishingStep] = [ 184 | .addMarkdownFiles() 185 | ], 186 | date: Date = Date(), 187 | content: [Path : String] = [:] 188 | ) throws { 189 | try publishWebsite(in: folder, using: [ 190 | .group(generationSteps), 191 | .generateRSSFeed( 192 | including: [.one], 193 | itemPredicate: itemPredicate, 194 | config: config, 195 | date: date 196 | ) 197 | ], content: content) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/SiteMapGenerationTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | import Files 10 | 11 | final class SiteMapGenerationTests: PublishTestCase { 12 | func testGeneratingSiteMap() throws { 13 | let folder = try Folder.createTemporary() 14 | 15 | try publishWebsite(in: folder, using: [ 16 | .addItem(.stub(withPath: "item")), 17 | .addPage(.stub(withPath: "page")), 18 | .generateSiteMap() 19 | ]) 20 | 21 | let file = try folder.file(at: "Output/sitemap.xml") 22 | let siteMap = try file.readAsString() 23 | 24 | let expectedLocations = [ 25 | "https://swiftbysundell.com/one", 26 | "https://swiftbysundell.com/one/item", 27 | "https://swiftbysundell.com/page" 28 | ] 29 | 30 | for location in expectedLocations { 31 | XCTAssertTrue(siteMap.contains("\(location)")) 32 | } 33 | } 34 | 35 | func testExcludingPathsFromSiteMap() throws { 36 | let folder = try Folder.createTemporary() 37 | 38 | let site = try publishWebsite(in: folder, using: [ 39 | .addItem(.stub(withPath: "itemA")), 40 | .addItem(.stub(withPath: "itemB")), 41 | .addItem(.stub(withPath: "itemC", sectionID: .two)), 42 | .addItem(.stub(withPath: "itemD", sectionID: .two)), 43 | .addItem(.stub(withPath: "itemE", sectionID: .three)), 44 | .addItem(.stub(withPath: "posts/itemF", sectionID: .three)), 45 | .addPage(.stub(withPath: "pageA")), 46 | .addPage(.stub(withPath: "pageB")), 47 | .generateSiteMap(excluding: [ 48 | "one/itemB", 49 | "two", 50 | "three/posts/", 51 | "pageB" 52 | ]) 53 | ]) 54 | 55 | let file = try folder.file(at: "Output/sitemap.xml") 56 | let siteMap = try file.readAsString() 57 | 58 | let expectedLocations = [ 59 | "https://swiftbysundell.com/one", 60 | "https://swiftbysundell.com/one/itemA", 61 | "https://swiftbysundell.com/three/itemE", 62 | "https://swiftbysundell.com/pageA" 63 | ] 64 | 65 | let unexpectedLocations = [ 66 | "https://swiftbysundell.com/one/itemB", 67 | "https://swiftbysundell.com/two", 68 | "https://swiftbysundell.com/two/itemC", 69 | "https://swiftbysundell.com/two/itemD", 70 | "https://swiftbysundell.com/three/posts/itemF", 71 | "https://swiftbysundell.com/pageB" 72 | ] 73 | 74 | for location in expectedLocations { 75 | XCTAssertTrue(siteMap.contains("\(location)")) 76 | } 77 | 78 | for location in unexpectedLocations { 79 | XCTAssertFalse(siteMap.contains("\(location)")) 80 | } 81 | 82 | XCTAssertNotNil(site.sections[.one].item(at: "itemB")) 83 | XCTAssertNotNil(site.sections[.two].item(at: "itemC")) 84 | XCTAssertNotNil(site.sections[.two].item(at: "itemD")) 85 | XCTAssertNotNil(site.sections[.three].item(at: "itemE")) 86 | XCTAssertNotNil(site.pages["pageB"]) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Tests/PublishTests/Tests/WebsiteTests.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Publish 3 | * Copyright (c) John Sundell 2019 4 | * MIT license, see LICENSE file for details 5 | */ 6 | 7 | import XCTest 8 | import Publish 9 | 10 | final class WebsiteTests: PublishTestCase { 11 | private var website: WebsiteStub.WithoutItemMetadata! 12 | 13 | override func setUp() { 14 | super.setUp() 15 | website = .init() 16 | } 17 | 18 | func testDefaultTagListPath() { 19 | XCTAssertEqual(website.tagListPath, "tags") 20 | } 21 | 22 | func testCustomTagListPath() { 23 | website.tagHTMLConfig = TagHTMLConfiguration(basePath: "custom") 24 | XCTAssertEqual(website.tagListPath, "custom") 25 | } 26 | 27 | func testPathForSectionID() { 28 | XCTAssertEqual(website.path(for: .one), "one") 29 | } 30 | 31 | func testPathForSectionIDWithRawValue() { 32 | XCTAssertEqual(website.path(for: .customRawValue), "custom-raw-value") 33 | } 34 | 35 | func testDefaultPathForTag() { 36 | let tag = Tag("some tag") 37 | XCTAssertEqual(website.path(for: tag), "tags/some-tag") 38 | } 39 | 40 | func testCustomPathForTag() { 41 | website.tagHTMLConfig = TagHTMLConfiguration(basePath: "custom") 42 | let tag = Tag("some tag") 43 | XCTAssertEqual(website.path(for: tag), "custom/some-tag") 44 | } 45 | 46 | func testDefaultURLForTag() { 47 | XCTAssertEqual( 48 | website.url(for: Tag("some tag")), 49 | URL(string: "https://swiftbysundell.com/tags/some-tag") 50 | ) 51 | } 52 | 53 | func testCustomURLForTag() { 54 | website.tagHTMLConfig = TagHTMLConfiguration(basePath: "custom") 55 | 56 | XCTAssertEqual( 57 | website.url(for: Tag("some tag")), 58 | URL(string: "https://swiftbysundell.com/custom/some-tag") 59 | ) 60 | } 61 | 62 | func testURLForRelativePath() { 63 | XCTAssertEqual( 64 | website.url(for: Path("a/path")), 65 | URL(string: "https://swiftbysundell.com/a/path") 66 | ) 67 | } 68 | 69 | func testURLForAbsolutePath() { 70 | XCTAssertEqual( 71 | website.url(for: Path("/a/path")), 72 | URL(string: "https://swiftbysundell.com/a/path") 73 | ) 74 | } 75 | 76 | func testURLForLocation() { 77 | let page = Page(path: "mypage", content: Content()) 78 | 79 | XCTAssertEqual( 80 | website.url(for: page), 81 | URL(string: "https://swiftbysundell.com/mypage") 82 | ) 83 | } 84 | } 85 | --------------------------------------------------------------------------------