-
Notifications
You must be signed in to change notification settings - Fork 337
/
PublishingStep.swift
499 lines (446 loc) · 18.6 KB
/
PublishingStep.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
/**
* Publish
* Copyright (c) John Sundell 2019
* MIT license, see LICENSE file for details
*/
import Foundation
import Files
import Plot
/// Type used to implement a publishing pipeline step.
/// Each Publish website is generated and deployed using steps, which can also
/// be combined into groups, and conditionally executed. Publish ships with many
/// built-in steps, and new ones can easily be defined using `step(named:body:)`.
/// Steps are added when calling `Website.publish`.
public struct PublishingStep<Site: Website> {
/// Closure type used to define the main body of a publishing step.
public typealias Closure = (inout PublishingContext<Site>) async throws -> Void
internal let kind: Kind
internal let body: Body
}
// MARK: - Core
public extension PublishingStep {
/// An empty step that does nothing. Can be used as a conditional fallback.
static var empty: Self {
PublishingStep(kind: .system, body: .empty)
}
/// Group an array of steps into one.
/// - parameter steps: The steps to use to form a group.
static func group(_ steps: [Self]) -> Self {
PublishingStep(kind: .system, body: .group(steps))
}
/// Conditionally run a step if an expression evaluates to `true`.
/// - parameter condition: The condition that determines whether the
/// step should be run or not.
/// - parameter step: The step to run if the condition is `true`.
static func `if`(_ condition: Bool, _ step: Self) -> Self {
condition ? step : .empty
}
/// Conditionally run a step if an optional isn't `nil`.
/// - parameter optional: The optional to unwrap.
/// - parameter transform: A closure that transforms any unwrapped
/// value into a `PublishingStep` instance.
static func unwrap<T>(_ optional: T?, _ transform: (T) -> Self) -> Self {
optional.map(transform) ?? .empty
}
/// Convert a step into an optional one, making it silently fail in
/// case it encountered an error.
/// - parameter step: The step to turn into an optional one.
static func optional(_ step: Self) -> Self {
switch step.body {
case .empty, .group:
return step
case .operation(let name, let closure):
return .step(named: name, kind: step.kind) { context in
do { try await closure(&context) }
catch {}
}
}
}
/// Install a plugin into this publishing process.
/// - parameter plugin: The plugin to install.
static func installPlugin(_ plugin: Plugin<Site>) -> Self {
step(
named: "Install plugin '\(plugin.name)'",
kind: .generation,
body: plugin.installer
)
}
/// Create a custom step.
/// - parameter name: A human-readable name for the step.
/// - parameter body: The step's closure body, which is used to
/// to mutate the current `PublishingContext`.
static func step(named name: String, body: @escaping Closure) -> Self {
step(named: name, kind: .generation, body: body)
}
}
// MARK: - Content
public extension PublishingStep {
/// Add an item to website programmatically.
/// - parameter item: The item to add.
static func addItem(_ item: Item<Site>) -> Self {
step(named: "Add item '\(item.path)'") { context in
context.addItem(item)
}
}
/// Add a sequence of items to website programmatically.
/// - parameter sequence: The items to add.
static func addItems<S: Sequence>(
in sequence: S
) -> Self where S.Element == Item<Site> {
step(named: "Add items in sequence") { context in
sequence.forEach { context.addItem($0) }
}
}
/// Add a page to website programmatically.
/// - parameter page: The page to add.
static func addPage(_ page: Page) -> Self {
step(named: "Add page '\(page.path)'") { context in
context.addPage(page)
}
}
/// Add a sequence of pages to website programmatically.
/// - parameter sequence: The pages to add.
static func addPages<S: Sequence>(
in sequence: S
) -> Self where S.Element == Page {
step(named: "Add pages in sequence") { context in
sequence.forEach { context.addPage($0) }
}
}
/// Parse a folder of Markdown files and use them to add content to
/// the website. The root folders will be parsed as sections, and the
/// files within them as items, while root files will be parsed as pages.
/// - parameter path: The path of the Markdown folder to add (default: `Content`).
static func addMarkdownFiles(at path: Path = "Content") -> Self {
step(named: "Add Markdown files from '\(path)' folder") { context in
let folder = try context.folder(at: path)
try await MarkdownFileHandler().addMarkdownFiles(in: folder, to: &context)
}
}
/// Remove all items matching a predicate, optionally within a specific section.
/// - parameter section: Any specific section to remove all items within.
/// - parameter predicate: Any predicate to filter the items using.
static func removeAllItems(
in section: Site.SectionID? = nil,
matching predicate: Predicate<Item<Site>> = .any
) -> Self {
let nameSuffix = section.map { " in '\($0)'" } ?? ""
return step(named: "Remove items" + nameSuffix) { context in
if let section = section {
context.sections[section].removeItems(matching: predicate)
} else {
for section in context.sections.ids {
context.sections[section].removeItems(matching: predicate)
}
}
}
}
/// Mutate all items matching a predicate, optionally within a specific section.
/// - parameter section: Any specific section to mutate all items within.
/// - parameter predicate: Any predicate to filter the items using.
/// - parameter mutations: The mutations to apply to each item.
static func mutateAllItems(
in section: Site.SectionID? = nil,
matching predicate: Predicate<Item<Site>> = .any,
using mutations: @escaping Mutations<Item<Site>>
) -> Self {
mutateAllItems(
in: section.map { [$0] } ?? Set(Site.SectionID.allCases),
matching: predicate,
using: mutations
)
}
/// Mutate all items matching a predicate within a certain set of sections.
/// - parameter sections: The sections to mutate all items within.
/// - parameter predicate: Any predicate to filter the items using.
/// - parameter mutations: The mutations to apply to each item.
static func mutateAllItems(
in sections: Set<Site.SectionID>,
matching predicate: Predicate<Item<Site>> = .any,
using mutations: @escaping Mutations<Item<Site>>
) -> Self {
var stepName = "Mutate all items"
if sections.count != Site.SectionID.allCases.count {
let sectionDescription = sections
.map(\.rawValue)
.joined(separator: ", ")
stepName.append(" in \(sectionDescription)")
}
return step(named: stepName) { context in
for section in sections {
try await context.sections[section].replaceItems(
with: context.sections[section].items.concurrentMap { item in
guard predicate.matches(item) else {
return item
}
do {
var item = item
try mutations(&item)
return item
} catch {
throw ContentError(
path: item.path,
reason: .itemMutationFailed(error)
)
}
}
)
}
}
}
/// Mutate an item at a given path within a section.
/// - parameter path: The relative path of the item to mutate.
/// - parameter section: The section that the item belongs to.
/// - parameter mutations: The mutations to apply to the item.
static func mutateItem(
at path: Path,
in section: Site.SectionID,
using mutations: @escaping Mutations<Item<Site>>
) -> Self {
step(named: "Mutate item at '\(path)' in \(section)") { context in
try context.sections[section].mutateItem(at: path, using: mutations)
}
}
/// Mutate a page at a given path.
/// - parameter path: The path of the page to mutate.
/// - parameter mutations: The mutations to apply to the page.
static func mutatePage(
at path: Path,
using mutations: @escaping Mutations<Page>
) -> Self {
step(named: "Mutate page at '\(path)'") { context in
try context.mutatePage(at: path, using: mutations)
}
}
/// Mutate all pages, optionally matching a given predicate.
/// - parameter predicate: Any predicate to filter the items using.
/// - parameter mutations: The mutations to apply to the page.
static func mutateAllPages(
matching predicate: Predicate<Page> = .any,
using mutations: @escaping Mutations<Page>
) -> Self {
step(named: "Mutate all pages") { context in
for path in context.pages.keys {
try context.mutatePage(
at: path,
matching: predicate,
using: mutations
)
}
}
}
/// Sort all items, optionally within a specific section, using a key path.
/// - parameter section: Any specific section to sort all items within.
/// - parameter keyPath: The key path to use when sorting.
/// - parameter order: The order to use when sorting.
static func sortItems<T: Comparable>(
in section: Site.SectionID? = nil,
by keyPath: KeyPath<Item<Site>, T>,
order: SortOrder = .ascending
) -> Self {
let nameSuffix = section.map { " in '\($0)'" } ?? ""
return step(named: "Sort items" + nameSuffix) { context in
let sorter = order.makeSorter(forKeyPath: keyPath)
if let section = section {
context.sections[section].sortItems(by: sorter)
} else {
for section in context.sections {
context.sections[section.id].sortItems(by: sorter)
}
}
}
}
}
// MARK: - Files and folders
public extension PublishingStep {
/// Copy the website's main resources into its output folder
/// - parameter originPath: The path that the resource folder is located at.
/// - parameter targetFolderPath: Any specific path to copy the resources to.
/// If `nil`, then the resources will be copied to the output folder itself.
/// - parameter includeFolder: Whether the resource folder itself, or just its
/// contents, should be copied. Default: `false`.
static func copyResources(
at originPath: Path = "Resources",
to targetFolderPath: Path? = nil,
includingFolder includeFolder: Bool = false
) -> Self {
copyFiles(at: originPath,
to: targetFolderPath,
includingFolder: includeFolder)
}
/// Copy a file at a given path into the website's output folder.
/// - parameter originPath: The path of the file to copy.
/// - parameter targetFolderPath: Any specific folder path to copy the file to.
/// If `nil`, then the file will be copied to the output folder itself.
static func copyFile(at originPath: Path,
to targetFolderPath: Path? = nil) -> Self {
step(named: "Copy file '\(originPath)'") { context in
try context.copyFileToOutput(
from: originPath,
to: targetFolderPath
)
}
}
/// Copy a folder at a given path into the website's output folder.
/// - parameter originPath: The path of the folder to copy.
/// - parameter targetFolderPath: Any specific path to copy the folder to.
/// If `nil`, then the folder will be copied to the output folder itself.
/// - parameter includeFolder: Whether the origin folder itself, or just its
/// contents, should be copied. Default: `false`.
static func copyFiles(
at originPath: Path,
to targetFolderPath: Path? = nil,
includingFolder includeFolder: Bool = false
) -> Self {
step(named: "Copy '\(originPath)' files") { context in
let folder = try context.folder(at: originPath)
if includeFolder {
try context.copyFolderToOutput(
folder,
targetFolderPath: targetFolderPath
)
} else {
for subfolder in folder.subfolders {
try context.copyFolderToOutput(
subfolder,
targetFolderPath: targetFolderPath
)
}
for file in folder.files {
try context.copyFileToOutput(
file,
targetFolderPath: targetFolderPath
)
}
}
}
}
}
// MARK: - Generation
public extension PublishingStep {
/// Generate the website's HTML using a given theme.
/// - parameter theme: The theme to use to generate the website's HTML.
/// - parameter indentation: How each HTML file should be indented.
/// - parameter fileMode: The mode to use when generating each HTML file.
static func generateHTML(
withTheme theme: Theme<Site>,
indentation: Indentation.Kind? = nil,
fileMode: HTMLFileMode = .foldersAndIndexFiles
) -> Self {
step(named: "Generate HTML") { context in
let generator = HTMLGenerator(
theme: theme,
indentation: indentation,
fileMode: fileMode,
context: context
)
try await generator.generate()
}
}
/// Generate an RSS feed for the website.
/// - parameter includedSectionIDs: The IDs of the sections which items
/// to include when generating the feed.
/// - parameter itemPredicate: A predicate used to determine whether to
/// include a given item within the generated feed (default: include all).
/// - parameter config: The configuration to use when generating the feed.
/// - parameter date: The date that should act as the build and publishing
/// date for the generated feed (default: the current date).
static func generateRSSFeed(
including includedSectionIDs: Set<Site.SectionID>,
itemPredicate: Predicate<Item<Site>>? = nil,
config: RSSFeedConfiguration = .default,
date: Date = Date()
) -> Self {
guard !includedSectionIDs.isEmpty else { return .empty }
return step(named: "Generate RSS feed") { context in
let generator = RSSFeedGenerator(
includedSectionIDs: includedSectionIDs,
itemPredicate: itemPredicate,
config: config,
context: context,
date: date
)
try await generator.generate()
}
}
/// Generate a site map for the website, which is an XML file used
/// for search engine indexing.
/// - parameter excludedPaths: Any paths to exclude from the site map.
/// Adding a section's path to the list removes the entire section, including all its items.
/// - parameter indentation: How the site map should be indented.
static func generateSiteMap(excluding excludedPaths: Set<Path> = [],
indentedBy indentation: Indentation.Kind? = nil) -> Self {
step(named: "Generate site map") { context in
let generator = SiteMapGenerator(
excludedPaths: excludedPaths,
indentation: indentation,
context: context
)
try generator.generate()
}
}
}
public extension PublishingStep where Site.ItemMetadata: PodcastCompatibleWebsiteItemMetadata {
/// Generate a podcast feed for one of the website's sections.
/// Note that all of the items within the given section must define `podcast`
/// and `audio` metadata, or an error will be thrown.
/// - parameter section: The section to generate a podcast feed for.
/// - parameter itemPredicate: A predicate used to determine whether to
/// include a given item within the generated feed (default: include all).
/// - parameter config: The configuration to use when generating the feed.
/// - parameter date: The date that should act as the build and publishing
/// date for the generated feed (default: the current date).
static func generatePodcastFeed(
for section: Site.SectionID,
itemPredicate: Predicate<Item<Site>>? = nil,
config: PodcastFeedConfiguration<Site>,
date: Date = Date()
) -> Self {
step(named: "Generate podcast feed") { context in
let generator = PodcastFeedGenerator(
sectionID: section,
itemPredicate: itemPredicate,
config: config,
context: context,
date: date
)
try await generator.generate()
}
}
}
// MARK: - Deployment
public extension PublishingStep {
/// Deploy the website using a given method.
/// This step will only run in case either the `-d` or `--deploy
/// flag was passed on the command line, for example by using the
/// `publish deploy` command.
/// - parameter method: The method to use when deploying the website.
static func deploy(using method: DeploymentMethod<Site>) -> Self {
step(named: "Deploy using \(method.name)", kind: .deployment) { context in
try method.body(context)
}
}
}
// MARK: - Implementation details
internal extension PublishingStep {
enum Kind: String {
case system
case generation
case deployment
}
enum Body {
case empty
case operation(name: String, closure: Closure)
case group([PublishingStep])
}
}
private extension PublishingStep {
static func step(named name: String,
kind: Kind,
body: @escaping Closure) -> Self {
PublishingStep(
kind: kind,
body: .operation(name: name, closure: body)
)
}
}