├── .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, "")
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 | ""
58 | }
59 | ))
60 | }],
61 | expectedHTML: ["index.html": ""]
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)  [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 |
\
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)  [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 |
\
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 |
--------------------------------------------------------------------------------