Skip to content

Commit 6fb960a

Browse files
authored
Reuse RSS/Podcast feeds if possible (#16)
This change makes Publish reuse any previously generated RSS and podcast feeds in case no new items have been added, no existing ones have been modified, and the configuration for generating the given feed hasn’t changed. This is made possible by series of other changes: - `PodcastFeedConfiguration` is no longer a subclass of its RSS counterpart, instead, both are separate structs that share a new `FeedConfiguration` protocol - in order to be able to enforce some degree of consistency. - Both feed configuration types have been made `Equatable` and `Codable` in order to persist and compare them to previous configs. - `PublishingContext` now has a `lastGenerationDate` API, which is used to compare the last modified item against when the website was last generated. - `PublishingContext` now also enables any step to create cache files, which is used by `RSSFeedGenerator` and `PodcastFeedGenerator` to store caches of their previous configs + feeds. - An error is now thrown if there are no runnable publishing steps, in order to be able to associate each `PublishingContext` with a current step name. - Publish’s internal string normalization code is now available to all strings, rather than just tags, and used when creating cache files. - Plot has been bumped to version `0.4.0`. The main motivation for this change is to not cause each new publishing process to mark all RSS and podcast feeds as changed, which makes it harder to track actual changes using version control.
1 parent 7a17e9e commit 6fb960a

18 files changed

+361
-88
lines changed

Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ let package = Package(
1616
],
1717
dependencies: [
1818
.package(url: "https://github.com/johnsundell/ink.git", from: "0.2.0"),
19-
.package(url: "https://github.com/johnsundell/plot.git", from: "0.3.0"),
19+
.package(url: "https://github.com/johnsundell/plot.git", from: "0.4.0"),
2020
.package(url: "https://github.com/johnsundell/files.git", from: "4.0.0"),
2121
.package(url: "https://github.com/johnsundell/codextended.git", from: "0.1.0"),
2222
.package(url: "https://github.com/johnsundell/shellout.git", from: "2.2.0"),
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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+
}

Sources/Publish/API/PodcastAuthor.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import Foundation
88

99
/// Type used to describe the author of a podcast.
10-
public struct PodcastAuthor {
10+
public struct PodcastAuthor: Codable, Equatable {
1111
/// The author's full name.
1212
public var name: String
1313
/// The author's email address.

Sources/Publish/API/PodcastFeedConfiguration.swift

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import Plot
1010
/// Configuration type used to customize how a podcast feed is generated when
1111
/// using the `generatePodcastFeed` step. To use a default implementation,
1212
/// use `PodcastFeedConfiguration.default`.
13-
public final class PodcastFeedConfiguration<Site: Website>: RSSFeedConfiguration {
13+
public struct PodcastFeedConfiguration<Site: Website>: FeedConfiguration {
14+
public var targetPath: Path
15+
public var ttlInterval: TimeInterval
16+
public var maximumItemCount: Int
17+
public var indentation: Indentation.Kind?
1418
/// The type of the podcast. See `PodcastType`.
1519
public var type: PodcastType
1620
/// A URL that points to the podcast's main image.
@@ -64,6 +68,10 @@ public final class PodcastFeedConfiguration<Site: Website>: RSSFeedConfiguration
6468
newFeedURL: URL? = nil,
6569
indentation: Indentation.Kind? = nil
6670
) {
71+
self.targetPath = targetPath
72+
self.ttlInterval = ttlInterval
73+
self.maximumItemCount = maximumItemCount
74+
self.indentation = indentation
6775
self.type = type
6876
self.imageURL = imageURL
6977
self.copyrightText = copyrightText
@@ -74,10 +82,5 @@ public final class PodcastFeedConfiguration<Site: Website>: RSSFeedConfiguration
7482
self.subcategory = subcategory
7583
self.isExplicit = isExplicit
7684
self.newFeedURL = newFeedURL
77-
78-
super.init(targetPath: targetPath,
79-
ttlInterval: ttlInterval,
80-
maximumItemCount: maximumItemCount,
81-
indentation: indentation)
8285
}
8386
}

Sources/Publish/API/PublishingContext.swift

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,19 @@ public struct PublishingContext<Site: Website> {
3131
public private(set) var pages = [Path : Page]()
3232
/// A set containing all tags that are currently being used website-wide.
3333
public var allTags: Set<Tag> { tagCache.tags ?? gatherAllTags() }
34+
/// Any date when the website was last generated.
35+
public private(set) var lastGenerationDate: Date?
3436

3537
private let folders: Folder.Group
3638
private var tagCache = TagCache()
39+
private var stepName: String
3740

38-
internal init(site: Site, folders: Folder.Group) {
41+
internal init(site: Site,
42+
folders: Folder.Group,
43+
firstStepName: String) {
3944
self.site = site
4045
self.folders = folders
46+
self.stepName = firstStepName
4147

4248
let dateFormatter = DateFormatter()
4349
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm"
@@ -160,6 +166,18 @@ public extension PublishingContext {
160166
}
161167
}
162168

169+
/// Return either an existing or newly created cache file for the
170+
/// current publishing step. Cache files are scoped to the step
171+
/// that they're created within, and can't be shared among steps.
172+
/// Cache files aren't deleted in between publishing processes.
173+
/// - parameter name: The name of the cache file to return.
174+
/// - throws: An error in case a new file couldn't be created.
175+
func cacheFile(named name: String) throws -> File {
176+
let folderName = stepName.normalized()
177+
let folder = try folders.caches.createSubfolderIfNeeded(withName: folderName)
178+
return try folder.createFileIfNeeded(withName: name.normalized())
179+
}
180+
163181
/// Return all items within this website, sorted by a given key path.
164182
/// - parameter sortingKeyPath: The key path to sort the items by.
165183
/// - parameter order: The order to use when sorting the items.
@@ -249,6 +267,14 @@ public extension PublishingContext {
249267
}
250268

251269
internal extension PublishingContext {
270+
mutating func generationWillBegin() {
271+
try? updateLastGenerationDate()
272+
}
273+
274+
mutating func prepareForStep(named name: String) {
275+
stepName = name
276+
}
277+
252278
func makeMarkdownContentFactory() -> MarkdownContentFactory<Site> {
253279
MarkdownContentFactory(
254280
parser: markdownParser,
@@ -280,6 +306,24 @@ private extension PublishingContext {
280306
var tags: Set<Tag>?
281307
}
282308

309+
mutating func updateLastGenerationDate() throws {
310+
let fileName = "lastGenerationDate"
311+
let newString = String(Date().timeIntervalSince1970)
312+
313+
if let file = try? folders.internal.file(named: fileName) {
314+
let oldInterval = try TimeInterval(file.readAsString())
315+
316+
lastGenerationDate = oldInterval.map {
317+
Date(timeIntervalSince1970: $0)
318+
}
319+
320+
try file.write(newString)
321+
} else {
322+
let file = try folders.internal.createFile(named: fileName)
323+
try file.write(newString)
324+
}
325+
}
326+
283327
func gatherAllTags() -> Set<Tag> {
284328
var tags = Set<Tag>()
285329

Sources/Publish/API/PublishingStep.swift

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,17 +331,21 @@ public extension PublishingStep {
331331
/// - parameter includedSectionIDs: The IDs of the sections which items
332332
/// to include when generating the feed.
333333
/// - parameter config: The configuration to use when generating the feed.
334+
/// - parameter date: The date that should act as the build and publishing
335+
/// date for the generated feed (default: the current date).
334336
static func generateRSSFeed(
335337
including includedSectionIDs: Set<Site.SectionID>,
336-
config: RSSFeedConfiguration = .default
338+
config: RSSFeedConfiguration = .default,
339+
date: Date = Date()
337340
) -> Self {
338341
guard !includedSectionIDs.isEmpty else { return .empty }
339342

340343
return step(named: "Generate RSS feed") { context in
341344
let generator = RSSFeedGenerator(
342345
includedSectionIDs: includedSectionIDs,
343346
config: config,
344-
context: context
347+
context: context,
348+
date: date
345349
)
346350

347351
try generator.generate()
@@ -373,15 +377,19 @@ public extension PublishingStep where Site.ItemMetadata: PodcastCompatibleWebsit
373377
/// and `audio` metadata, or an error will be thrown.
374378
/// - parameter section: The section to generate a podcast feed for.
375379
/// - parameter config: The configuration to use when generating the feed.
380+
/// - parameter date: The date that should act as the build and publishing
381+
/// date for the generated feed (default: the current date).
376382
static func generatePodcastFeed(
377383
for section: Site.SectionID,
378-
config: PodcastFeedConfiguration<Site>
384+
config: PodcastFeedConfiguration<Site>,
385+
date: Date = Date()
379386
) -> Self {
380387
step(named: "Generate podcast feed") { context in
381388
let generator = PodcastFeedGenerator(
382389
sectionID: section,
383390
config: config,
384-
context: context
391+
context: context,
392+
date: date
385393
)
386394

387395
try generator.generate()
@@ -407,7 +415,7 @@ public extension PublishingStep {
407415
// MARK: - Implementation details
408416

409417
internal extension PublishingStep {
410-
enum Kind {
418+
enum Kind: String {
411419
case system
412420
case generation
413421
case deployment

Sources/Publish/API/RSSFeedConfiguration.swift

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,10 @@ import Plot
1010
/// Configuration type used to customize how an RSS feed is generated
1111
/// when using the `generateRSSFeed` step. To use a default implementation,
1212
/// use `RSSFeedConfiguration.default`.
13-
public class RSSFeedConfiguration {
14-
/// The path that the feed should be generated at.
13+
public struct RSSFeedConfiguration: FeedConfiguration {
1514
public var targetPath: Path
16-
/// The feed's TTL (or "Time to live") time interval.
1715
public var ttlInterval: TimeInterval
18-
/// The maximum number of items that the feed should contain.
1916
public var maximumItemCount: Int
20-
/// How the feed should be indented.
2117
public var indentation: Indentation.Kind?
2218

2319
/// Initialize a new configuration instance.

Sources/Publish/API/Tag.swift

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,6 @@ public extension Tag {
2020
/// Return a normalized string representation of this tag, which can
2121
/// be used to form URLs or identifiers.
2222
func normalizedString() -> String {
23-
String(string.lowercased().compactMap { character in
24-
if character.isWhitespace {
25-
return "-"
26-
}
27-
28-
if character.isLetter || character.isNumber {
29-
return character
30-
}
31-
32-
return nil
33-
})
23+
string.normalized()
3424
}
3525
}

Sources/Publish/Internal/Folder+Group.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ internal extension Folder {
1111
let root: Folder
1212
let output: Folder
1313
let `internal`: Folder
14+
let caches: Folder
1415
}
1516
}

0 commit comments

Comments
 (0)