diff --git a/Package.resolved b/Package.resolved index d3d55a8b..1b211b58 100644 --- a/Package.resolved +++ b/Package.resolved @@ -28,6 +28,15 @@ "version": "4.2.0" } }, + { + "package": "FileWatcher", + "repositoryURL": "https://github.com/eonist/FileWatcher.git", + "state": { + "branch": null, + "revision": "e67c2a99502eade343fecabeca8c57e749a55b59", + "version": "0.2.3" + } + }, { "package": "Ink", "repositoryURL": "https://github.com/johnsundell/ink.git", diff --git a/Package.swift b/Package.swift index f81064cc..e6df55e1 100644 --- a/Package.swift +++ b/Package.swift @@ -50,7 +50,13 @@ let package = Package( name: "CollectionConcurrencyKit", url: "https://github.com/johnsundell/collectionConcurrencyKit.git", from: "0.1.0" + ), + .package( + name: "FileWatcher", + url: "https://github.com/eonist/FileWatcher.git", + from: "0.2.3" ) + ], targets: [ .target( @@ -66,7 +72,7 @@ let package = Package( ), .target( name: "PublishCLICore", - dependencies: ["Publish"] + dependencies: ["Publish", .byName(name: "FileWatcher", condition: .when(platforms: [.macOS]))] ), .testTarget( name: "PublishTests", diff --git a/Sources/PublishCLICore/CLI.swift b/Sources/PublishCLICore/CLI.swift index 128bdf26..7cd6b522 100644 --- a/Sources/PublishCLICore/CLI.swift +++ b/Sources/PublishCLICore/CLI.swift @@ -9,6 +9,7 @@ import Foundation import ShellOut public struct CLI { + private static let defaultPortNumber = 8000 private let arguments: [String] private let publishRepositoryURL: URL private let publishVersion: String @@ -44,7 +45,8 @@ public struct CLI { try deployer.deploy() case "run": let portNumber = extractPortNumber(from: arguments) - let runner = WebsiteRunner(folder: folder, portNumber: portNumber) + let shouldWatch = extractShouldWatch(from: arguments) + let runner = WebsiteRunner(folder: folder, portNumber: portNumber, shouldWatch: shouldWatch) try runner.run() default: outputHelpText() @@ -68,24 +70,30 @@ private extension CLI { - run: Generate and run a localhost server on default port 8000 for the website in the current folder. Use the "-p" or "--port" option for customizing the default port. + Use the "-w" or "--watch" option to watch for file changes. - deploy: Generate and deploy the website in the current folder, according to its deployment method. """) } private func extractPortNumber(from arguments: [String]) -> Int { - if arguments.count > 3 { - switch arguments[2] { - case "-p", "--port": - guard let portNumber = Int(arguments[3]) else { - break - } - return portNumber - default: - return 8000 // default portNumber - } + guard let index = arguments.firstIndex(of: "-p") ?? arguments.firstIndex(of: "--port") else { + return Self.defaultPortNumber } - return 8000 // default portNumber + + guard arguments.count > index + 1 else { + return Self.defaultPortNumber + } + + guard let portNumber = Int(arguments[index + 1]) else { + return Self.defaultPortNumber + } + + return portNumber + } + + private func extractShouldWatch(from arguments: [String]) -> Bool { + arguments.contains("-w") || arguments.contains("--watch") } private func resolveProjectKind(from arguments: [String]) -> ProjectKind { diff --git a/Sources/PublishCLICore/WebsiteRunner.swift b/Sources/PublishCLICore/WebsiteRunner.swift index 326a235c..6a0563ec 100644 --- a/Sources/PublishCLICore/WebsiteRunner.swift +++ b/Sources/PublishCLICore/WebsiteRunner.swift @@ -1,20 +1,88 @@ /** -* Publish -* Copyright (c) John Sundell 2019 -* MIT license, see LICENSE file for details -*/ + * Publish + * Copyright (c) John Sundell 2019 + * MIT license, see LICENSE file for details + */ import Foundation import Files import ShellOut +#if canImport(FileWatcher) +import FileWatcher +#endif + internal struct WebsiteRunner { + static let nanosecondsPerSecond: UInt64 = 1_000_000_000 + static let normalTerminationStatus = 15 + static let debounceDuration = 3 * nanosecondsPerSecond + static let runLoopInterval: TimeInterval = 0.1 + static let exitMessage = "Press CTRL+C to stop the server and exit" let folder: Folder - var portNumber: Int + let portNumber: Int + let shouldWatch: Bool + + var foldersToWatch: [Folder] { + get throws { + try ["Sources", "Resources", "Content"].map(folder.subfolder(named:)) + } + } func run() throws { + let serverProcess: Process = try generateAndRun() + let watchTask = shouldWatch ? watch() : nil + + let interruptHandler = registerInterruptHandler { + watchTask?.cancel() + serverProcess.terminate() + exit(0) + } + + interruptHandler.resume() + + while true { + RunLoop.main.run(until: Date(timeIntervalSinceNow: Self.runLoopInterval)) + } + } +} + +private extension WebsiteRunner { + func registerInterruptHandler(_ handler: @escaping () -> Void) -> DispatchSourceSignal { + let interruptHandler = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main) + + signal(SIGINT, SIG_IGN) + + interruptHandler.setEventHandler(handler: handler) + return interruptHandler + } + + func watch() -> Task? { +#if canImport(FileWatcher) + return Task.detached { + for try await _ in FileWatcher.changes(on: try foldersToWatch, debouncedBy: Self.debounceDuration) { + print("Changes detected, regenerating...") + let generator = WebsiteGenerator(folder: folder) + do { + try generator.generate() + print(Self.exitMessage) + } catch { + outputErrorMessage("Regeneration failed") + } + } + } +#else + print("File watching not available") + return nil +#endif + } + + func generate() throws { let generator = WebsiteGenerator(folder: folder) try generator.generate() + } + + func generateAndRun() throws -> Process { + try generate() let outputFolder = try resolveOutputFolder() @@ -24,7 +92,7 @@ internal struct WebsiteRunner { print(""" šŸŒ Starting web server at http://localhost:\(portNumber) - Press ENTER to stop the server and exit + \(Self.exitMessage) """) serverQueue.async { @@ -44,12 +112,9 @@ internal struct WebsiteRunner { exit(1) } - _ = readLine() - serverProcess.terminate() + return serverProcess } -} -private extension WebsiteRunner { func resolveOutputFolder() throws -> Folder { do { return try folder.subfolder(named: "Output") } catch { throw CLIError.outputFolderNotFound } @@ -70,6 +135,60 @@ private extension WebsiteRunner { """ } - fputs("\nāŒ Failed to start local web server:\n\(message)\n", stderr) + outputErrorMessage("Failed to start local web server:\n\(message)") + } + + func outputErrorMessage(_ message: String) { + fputs("\nāŒ \(message)\n", stderr) + } +} + +#if canImport(FileWatcher) +private extension FileWatcher { + static func changes(on folders: [Folder], debouncedBy nanoseconds: UInt64?) -> AsyncThrowingStream { + .init { continuation in + let watcher = FileWatcher(folders.map(\.path)) + + var deferredTask: Task? + + watcher.callback = { event in + guard event.isFileChanged || event.isDirectoryChanged else { + return + } + + guard let nanoseconds = nanoseconds else { + continuation.yield(event.path) + return + } + + deferredTask?.cancel() + + deferredTask = Task { + do { + try await Task.sleep(nanoseconds: nanoseconds) + continuation.yield(event.path) + } catch where !(error is CancellationError) { + continuation.finish() + } + } + } + + watcher.start() + + continuation.onTermination = { _ in + watcher.stop() + } + } + } +} + +private extension FileWatcherEvent { + var isFileChanged: Bool { + fileRenamed || fileRemoved || fileCreated || fileModified + } + + var isDirectoryChanged: Bool { + dirRenamed || dirRemoved || dirCreated || dirModified } } +#endif