diff --git a/Sources/Publish/API/PublishingContext.swift b/Sources/Publish/API/PublishingContext.swift index 4dd14caa..5b681071 100644 --- a/Sources/Publish/API/PublishingContext.swift +++ b/Sources/Publish/API/PublishingContext.swift @@ -293,6 +293,7 @@ internal extension PublishingContext { func copyFileToOutput(_ file: File, targetFolderPath: Path?) throws { + guard !site.shouldIgnore(file) else { return } try copyLocationToOutput( file, targetFolderPath: targetFolderPath, @@ -302,6 +303,7 @@ internal extension PublishingContext { func copyFolderToOutput(_ folder: Folder, targetFolderPath: Path?) throws { + guard !site.shouldIgnore(folder) else { return } try copyLocationToOutput( folder, targetFolderPath: targetFolderPath, @@ -349,6 +351,7 @@ private extension PublishingContext { targetFolderPath: Path?, errorReason: FileIOError.Reason ) throws { + guard !site.shouldIgnore(location) else { return } let targetFolder = try targetFolderPath.map { try createOutputFolder(at: $0) } diff --git a/Sources/Publish/API/Website.swift b/Sources/Publish/API/Website.swift index 75ae2b5a..a278f7e9 100644 --- a/Sources/Publish/API/Website.swift +++ b/Sources/Publish/API/Website.swift @@ -6,6 +6,7 @@ import Foundation import Plot +import Files /// Protocol that all `Website.SectionID` implementations must conform to. public protocol WebsiteSectionID: Decodable, Hashable, CaseIterable, RawRepresentable where RawValue == String {} @@ -40,6 +41,12 @@ public protocol Website { /// The configuration to use when generating tag HTML for the website. /// If this is `nil`, then no tag HTML will be generated. var tagHTMLConfig: TagHTMLConfiguration? { get } + /// File or folder names to exclude when publishing the site. Regular expressions may be used. + /// - Bare strings with no regexp-related characters will be matched exactly. For example `"templates"` will ignore anything named `templates` but not something named `templates1` or `my templates`, etc. + /// - Wildcards are allowed and follow usual regular expression meanings. For example, `templates.*` means to ignore anything that starts with `templates` and includes zero or more characters after `templates`. + /// - It's not necessary to use `^` or `$` to indicate the start or end of a regular expression, but it's not harmful either. + /// If a folder is ignored, everything in that folder will be ignored, regardless of whether the folder contents match anything in `ignoredPaths`. + var ignoredPaths: [String]? { get } } // MARK: - Defaults @@ -154,4 +161,39 @@ public extension Website { func url(for location: Location) -> URL { url(for: location.path) } + + /// Make `ignoredPaths` optional. + var ignoredPaths: [String]? { nil } + + /// Should the site ignore a file or folder with a specific name? + /// - Parameter name: Name of a file or folder, commonly `file.name` or `folder.name`. + /// - Returns: True if the web site should ignore the file/folder name + /// + /// Sites can indicate file/folder names to ignore using the `ignoredPaths` property. See that property for a discussion of how to use it. + /// + /// This function checks only strings and should be called with a file or folder name. + func shouldIgnore(name: String) -> Bool { + guard let ignoredPaths = ignoredPaths else { return false } + return !ignoredPaths.filter({ + // Add `^` and `$` to the ends of the pattern to avoid unexpected matches. Matching something like "foo" anywhere in a filename requires wildcards, e.g. ".*foo.*". Extra line start/end markers don't affect matching, so there's no need to check for them before trying the pattern. + name.range(of: "^\($0)$", options: .regularExpression) != nil + }).isEmpty + } + + /// Should the site ignore a `File` or `Folder`? + /// - Returns: True if the site should ignore the `File`/`Folder`. + /// + /// Sites can indicate file/folder names to ignore using the `ignoredPaths` property. See that property for a discussion of how to use it. + /// + /// *This function checks all path components for the location.* A file or folder that doesn't match the ignore list could still be contained in a folder that does match. This function deals with this by checking all path components against the `ignoredPaths` list and returning true if any matches are found. If you want to check a file or folder's name without checking the entire path, use `shouldIgnore(name:)` + func shouldIgnore(_ location: T) -> Bool { + !location.pathComponents.filter({ shouldIgnore(name:$0) }).isEmpty + } +} + +extension Files.Location { + // Used in `shouldIgnore(_ location)` + var pathComponents: [String] { + path.split(separator: "/").map { String($0) } + } } diff --git a/Sources/Publish/Internal/MarkdownFileHandler.swift b/Sources/Publish/Internal/MarkdownFileHandler.swift index 14dd36f9..3155ef20 100644 --- a/Sources/Publish/Internal/MarkdownFileHandler.swift +++ b/Sources/Publish/Internal/MarkdownFileHandler.swift @@ -22,6 +22,7 @@ internal struct MarkdownFileHandler { } for subfolder in folder.subfolders { + guard !context.site.shouldIgnore(subfolder) else { continue } guard let sectionID = Site.SectionID(rawValue: subfolder.name.lowercased()) else { try addPagesForMarkdownFiles( inFolder: subfolder, @@ -35,6 +36,7 @@ internal struct MarkdownFileHandler { } for file in subfolder.files.recursive { + guard !context.site.shouldIgnore(file) else { continue } guard file.isMarkdown else { continue } if file.nameExcludingExtension == "index", file.parent == subfolder { @@ -86,6 +88,7 @@ private extension MarkdownFileHandler { factory: MarkdownContentFactory ) throws { for file in folder.files { + guard !context.site.shouldIgnore(file) else { continue } guard file.isMarkdown else { continue } if file.nameExcludingExtension == "index", !recursively { diff --git a/Tests/PublishTests/Tests/WebsiteTests.swift b/Tests/PublishTests/Tests/WebsiteTests.swift index 1ab15744..a00e009c 100644 --- a/Tests/PublishTests/Tests/WebsiteTests.swift +++ b/Tests/PublishTests/Tests/WebsiteTests.swift @@ -6,6 +6,7 @@ import XCTest import Publish +import Files final class WebsiteTests: PublishTestCase { private var website: WebsiteStub.WithoutItemMetadata! @@ -81,4 +82,53 @@ final class WebsiteTests: PublishTestCase { URL(string: "https://swiftbysundell.com/mypage") ) } + + func testIgnoreStrings() { + XCTAssertTrue(website.shouldIgnore(name: "templates")) + XCTAssertFalse(website.shouldIgnore(name: "templates1")) + XCTAssertFalse(website.shouldIgnore(name: "@templates")) + + XCTAssertTrue(website.shouldIgnore(name: "skip-this-file.md")) + XCTAssertTrue(website.shouldIgnore(name: "skip-this-file-too.png")) + XCTAssertTrue(website.shouldIgnore(name: "skip-this-file")) + XCTAssertFalse(website.shouldIgnore(name: "dont-skip-this-file")) + + XCTAssertTrue(website.shouldIgnore(name: "notes")) + XCTAssertFalse(website.shouldIgnore(name: "notes1")) + + XCTAssertTrue(website.shouldIgnore(name: "batter")) + XCTAssertTrue(website.shouldIgnore(name: "butter")) + XCTAssertFalse(website.shouldIgnore(name: "butter1")) + + XCTAssertTrue(website.shouldIgnore(name: "lisp")) + XCTAssertTrue(website.shouldIgnore(name: "lip")) + XCTAssertTrue(website.shouldIgnore(name: "lp")) + XCTAssertTrue(website.shouldIgnore(name: "l1234567890p")) + XCTAssertFalse(website.shouldIgnore(name: "lisp-too")) + } + + func testIgnoreFiles() { + // Set up a couple of real files to test ignore-matching on a File, since they require existing files. + let folder: Folder! = try! Folder.temporary.createSubfolderIfNeeded(withName: ".publishTest") + let includeFile = try! folder.createFile(named: "include.txt") + XCTAssertFalse(website.shouldIgnore(includeFile)) + + let ignoreFile = try! folder.createFile(named: "ignore.txt") + XCTAssertTrue(website.shouldIgnore(ignoreFile)) + + try? folder.delete() + } } + +extension Website { + var ignoredPaths: [String]? { [ + "templates", // Should only match the exact string + "skip-this-file.*", // Should match anyhing starting with "skip-this-file" and 0 or more chars after that + "^notes$", // Should match "notes" exactly and nothing else. Included because `shouldIgnore` adds a "^" and "$", which should not be affected by including those in the pattern. + "b.tter", + "l.*p", +// (#file as NSString).lastPathComponent + "ignore.txt" + ] } +} +