From 6a9f85b1d7b26167d614cc21aaaf3032deee223f Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Thu, 19 Mar 2026 15:46:12 +0100 Subject: [PATCH 1/3] feat: Instead of `saga dev` always starting a brand new build on every file change, Saga is now kept alive and just reruns the pipeline, while caching the entire read step --- Sources/Saga/Item.swift | 2 +- Sources/Saga/Saga+Private.swift | 52 ++++++++++- Sources/Saga/Saga.swift | 159 ++++++++++++++++++++++++-------- Sources/Saga/StepBuilder.swift | 16 ++-- 4 files changed, 181 insertions(+), 48 deletions(-) diff --git a/Sources/Saga/Item.swift b/Sources/Saga/Item.swift index 7e8cc69..bad871b 100644 --- a/Sources/Saga/Item.swift +++ b/Sources/Saga/Item.swift @@ -66,7 +66,7 @@ public class Item: AnyItem, Codable, @unchecked Sendable { public var children: [AnyItem] = [] /// Type-erased parent. Populated automatically by nested registrations. - public var parent: AnyItem? = nil + public weak var parent: AnyItem? = nil /// Typed accessor for children. public func children(as type: C.Type) -> [Item] { diff --git a/Sources/Saga/Saga+Private.swift b/Sources/Saga/Saga+Private.swift index c2b0b70..8670d75 100644 --- a/Sources/Saga/Saga+Private.swift +++ b/Sources/Saga/Saga+Private.swift @@ -47,6 +47,33 @@ extension Saga { return result } + /// Reset mutable pipeline state between dev rebuilds. + func reset() throws { + allItems = [] + handledPaths = [] + generatedPages = [] + contentHashes = [:] + + // Re-scan input files (files may have been added/removed) + let allFound = try fileIO.findFiles(inputPath).filter { $0.lastComponentWithoutExtension != ".DS_Store" } + files = allFound.map { path in + let relativePath = (try? path.relativePath(from: inputPath)) ?? Path("") + return (path: path, relativePath: relativePath) + } + } + + /// Wait for SIGUSR1, then return. + func waitForSignal() async { + await withCheckedContinuation { continuation in + let source = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .main) + source.setEventHandler { + source.cancel() + continuation.resume() + } + source.resume() + } + } + /// Read files from disk using a Reader, turns them into Items func readItems( folder: Path?, @@ -55,7 +82,8 @@ extension Saga { filter: @escaping @Sendable (Item) -> Bool, claimExcludedItems: Bool, itemWriteMode: ItemWriteMode, - sorting: @escaping @Sendable (Item, Item) -> Bool + sorting: @escaping @Sendable (Item, Item) -> Bool, + cacheKey: String ) async throws -> [Item] { // Filter to only files that match the folder (if any) and have a supported reader let relevant = unhandledFiles.filter { file in @@ -65,6 +93,9 @@ extension Saga { return readers.contains { $0.supportedExtensions.contains(file.path.extension ?? "") } } + // In-memory cache from previous dev rebuilds (keyed by relative path) + let cache = readerCache[cacheKey] ?? [:] + // Process files in parallel with deterministic result ordering let items = try await withThrowingTaskGroup(of: FileReadResult.self) { group in for file in relevant { @@ -74,6 +105,19 @@ extension Saga { return FileReadResult(filePath: file.path, item: nil, claimFile: false) } + // Check in-memory cache: if the file hasn't changed, reuse the cached item + let currentModDate = self.fileIO.modificationDate(file.path) + if let cached = cache[file.relativePath.string] as? Item, + let currentModDate, + cached.lastModified == currentModDate + { + if filter(cached) { + return FileReadResult(filePath: file.path, item: cached, claimFile: !reader.copySourceFiles) + } else { + return FileReadResult(filePath: file.path, item: nil, claimFile: claimExcludedItems) + } + } + do { // Use the Reader to convert the contents of the file to HTML let partial = try await reader.convert(file.path) @@ -124,8 +168,9 @@ extension Saga { } } - // Collect results serially — safe to update handledPaths here + // Collect results serially — safe to update handledPaths and cache here var items: [Item] = [] + var updatedCache: [String: AnyItem] = cache for try await result in group { if result.claimFile { self.handledPaths.insert(result.filePath) @@ -133,9 +178,12 @@ extension Saga { if let item = result.item { items.append(item) + updatedCache[item.relativeSource.string] = item } } + self.readerCache[cacheKey] = updatedCache + return items } diff --git a/Sources/Saga/Saga.swift b/Sources/Saga/Saga.swift index 7021dcd..86854b5 100644 --- a/Sources/Saga/Saga.swift +++ b/Sources/Saga/Saga.swift @@ -42,6 +42,13 @@ public class Saga: StepBuilder, @unchecked Sendable { /// Post processors var postProcessors: [@Sendable (String, Path) throws -> String] = [] + /// Hooks + var beforeReadHooks: [@Sendable (Saga) async throws -> Void] = [] + var afterWriteHooks: [@Sendable (Saga) async throws -> Void] = [] + + /// In-memory reader cache that survives between dev rebuilds + var readerCache: [String: [String: AnyItem]] = [:] + public init(input: Path, output: Path = "deploy", fileIO: FileIO = .diskAccess, originFilePath: StaticString = #filePath) throws { let originFile = Path("\(originFilePath)") let rootPath = try fileIO.resolveSwiftPackageFolder(originFile) @@ -61,6 +68,42 @@ public class Saga: StepBuilder, @unchecked Sendable { super.init(files: computedFiles, workingPath: Path("")) } + /// Register a hook that runs before the read phase of each build cycle. + /// + /// Use this for pre-build steps like CSS compilation: + /// ```swift + /// try await Saga(input: "content", output: "deploy") + /// .beforeRead { _ in + /// try await tailwind.run(input: "content/static/input.css", output: "content/static/output.css") + /// } + /// .register(...) + /// .run() + /// ``` + @discardableResult + @preconcurrency + public func beforeRead(_ hook: @Sendable @escaping (Saga) async throws -> Void) -> Self { + beforeReadHooks.append(hook) + return self + } + + /// Register a hook that runs after the write phase of each build cycle. + /// + /// Use this for post-build steps like search indexing: + /// ```swift + /// try await Saga(input: "content", output: "deploy") + /// .register(...) + /// .afterWrite { saga in + /// // run pagefind, etc. + /// } + /// .run() + /// ``` + @discardableResult + @preconcurrency + public func afterWrite(_ hook: @Sendable @escaping (Saga) async throws -> Void) -> Self { + afterWriteHooks.append(hook) + return self + } + /// Apply a transform to every file written by Saga. /// /// The transform receives the rendered content and relative output path. @@ -84,59 +127,97 @@ public class Saga: StepBuilder, @unchecked Sendable { /// Execute all the registered steps. @discardableResult public func run() async throws -> Self { - let totalStart = DispatchTime.now() - log("Starting run") - - // Run all the readers for all the steps sequentially to ensure proper order, - // which turns raw content into Items, and stores them within the step. - let readStart = DispatchTime.now() - for step in steps { - let items = try await step.read(self) - allItems.append(contentsOf: items) + // In dev mode, ignore SIGUSR1 at the process level so DispatchSource can handle it + if Saga.isDev { + signal(SIGUSR1, SIG_IGN) } - log("Finished read phase in \(elapsed(from: readStart))") + while true { + let totalStart = DispatchTime.now() + log("Starting run") - // Sort all items by date descending - allItems.sort { $0.date > $1.date } + if !beforeReadHooks.isEmpty { + let start = DispatchTime.now() + for hook in beforeReadHooks { + try await hook(self) + } - // Clean the output folder - try fileIO.deletePath(outputPath) + log("Finished beforeRead hooks in \(elapsed(from: start))") + } - // Copy all unhandled files as-is to the output folder first, - // so that the directory structure exists for the write phase. - let copyStart = DispatchTime.now() + // Run all the readers for all the steps sequentially to ensure proper order, + // which turns raw content into Items, and stores them within the step. + let readStart = DispatchTime.now() + for step in steps { + let items = try await step.read(self) + allItems.append(contentsOf: items) + } + + log("Finished read phase in \(elapsed(from: readStart))") + + // Sort all items by date descending + allItems.sort { $0.date > $1.date } - try await withThrowingTaskGroup(of: Void.self) { group in - for file in unhandledFiles { - group.addTask { - let output = self.outputPath + file.relativePath - try self.fileIO.mkpath(output.parent()) - try self.fileIO.copy(file.path, output) + // Clean the output folder + try fileIO.deletePath(outputPath) + + // Copy all unhandled files as-is to the output folder first, + // so that the directory structure exists for the write phase. + let copyStart = DispatchTime.now() + try await withThrowingTaskGroup(of: Void.self) { group in + for file in unhandledFiles { + group.addTask { + let output = self.outputPath + file.relativePath + try self.fileIO.mkpath(output.parent()) + try self.fileIO.copy(file.path, output) + } } + try await group.waitForAll() } - try await group.waitForAll() - } - log("Finished copying static files in \(elapsed(from: copyStart))") + log("Finished copying static files in \(elapsed(from: copyStart))") - // Make Saga.hashed() work - setupHashFunction() + // Make Saga.hashed() work + setupHashFunction() - // Run all writers sequentially - // processedWrite tracks generated paths automatically. - let writeStart = DispatchTime.now() - for step in steps { - try await step.write(self) - } + // Run all writers sequentially + // processedWrite tracks generated paths automatically. + let writeStart = DispatchTime.now() + for step in steps { + try await step.write(self) + } + + log("Finished write phase in \(elapsed(from: writeStart))") - log("Finished write phase in \(elapsed(from: writeStart))") + // Copy hashed versions of files that were referenced via Saga.hashed() + try copyHashedFiles() - // Copy hashed versions of files that were referenced via Saga.hashed() - try copyHashedFiles() + if !afterWriteHooks.isEmpty { + let start = DispatchTime.now() + for hook in afterWriteHooks { + try await hook(self) + } - log("All done in \(elapsed(from: totalStart))") + log("Finished afterWrite hooks in \(elapsed(from: start))") + } - return self + log("All done in \(elapsed(from: totalStart))") + + // In dev mode, signal the CLI that the build is done, then wait for SIGUSR1 + if Saga.isDev { + // Signal the parent process (saga dev) that the build completed + if let pidString = ProcessInfo.processInfo.environment["SAGA_DEV_PID"], + let pid = Int32(pidString) + { + kill(pid, SIGUSR2) + } + + await waitForSignal() + try reset() + continue + } + + return self + } // while true } } diff --git a/Sources/Saga/StepBuilder.swift b/Sources/Saga/StepBuilder.swift index 61fefbe..896bd1a 100644 --- a/Sources/Saga/StepBuilder.swift +++ b/Sources/Saga/StepBuilder.swift @@ -22,7 +22,7 @@ struct PipelineStep: @unchecked Sendable { /// A builder that collects pipeline steps. public class StepBuilder: @unchecked Sendable { var steps: [PipelineStep] = [] - let files: [(path: Path, relativePath: Path)] + var files: [(path: Path, relativePath: Path)] let workingPath: Path // relative to inputPath init(files: [(path: Path, relativePath: Path)], workingPath: Path) { @@ -109,7 +109,8 @@ public class StepBuilder: @unchecked Sendable { itemWriteMode: itemWriteMode, sorting: effectiveSorting, writers: writers, - outputPrefix: effectiveFolder + outputPrefix: effectiveFolder, + cacheKey: "reader-\(steps.count)" )) return self @@ -119,7 +120,7 @@ public class StepBuilder: @unchecked Sendable { nonisolated(unsafe) var items: [Item] = [] steps.append(PipelineStep( - read: { saga in + read: { [steps] saga in items = try await saga.readItems( folder: effectiveFolder, readers: readers, @@ -127,7 +128,8 @@ public class StepBuilder: @unchecked Sendable { filter: filter, claimExcludedItems: claimExcludedItems, itemWriteMode: itemWriteMode, - sorting: effectiveSorting + sorting: effectiveSorting, + cacheKey: "reader-\(steps.count)" ) return items }, @@ -296,7 +298,8 @@ public class StepBuilder: @unchecked Sendable { itemWriteMode: ItemWriteMode, sorting: @escaping @Sendable (Item, Item) -> Bool, writers: [Writer], - outputPrefix: Path + outputPrefix: Path, + cacheKey: String ) -> PipelineStep { nonisolated(unsafe) var parentItems: [Item] = [] @@ -312,7 +315,8 @@ public class StepBuilder: @unchecked Sendable { filter: filter, claimExcludedItems: claimExcludedItems, itemWriteMode: itemWriteMode, - sorting: sorting + sorting: sorting, + cacheKey: cacheKey ) guard let first = readItems.first else { continue } parentItem = first From 2b94f28901b6c570e1ae818e795f7b36d34505ee Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Thu, 19 Mar 2026 16:16:45 +0100 Subject: [PATCH 2/3] Update docs --- CLAUDE.md | 12 ++++-- Sources/Saga/Saga.docc/AdvancedUsage.md | 22 ++++++++++ Sources/Saga/Saga.docc/Architecture.md | 7 ++-- Sources/Saga/Saga.docc/Guides/AddingSearch.md | 18 ++++---- Sources/Saga/Saga.docc/Guides/TailwindCSS.md | 41 +++++++++++-------- 5 files changed, 66 insertions(+), 34 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 559b5c4..49ed1e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -43,11 +43,13 @@ The legacy `watch` command (`Sources/SagaWatch/`) is deprecated in favor of `sag ## Architecture Overview -Saga is a static site generator written in Swift that follows a **Reader → Processor → Writer** pipeline pattern: +Saga is a static site generator written in Swift that follows a **beforeRead → Reader → Processor → Writer → afterWrite** pipeline pattern: -1. **Readers** parse content files (Markdown, etc.) into strongly typed `Item` objects -2. **Processors** transform items with custom logic and filtering -3. **Writers** generate output files using various rendering contexts +1. **beforeRead hooks** run pre-build steps (e.g. CSS compilation) +2. **Readers** parse content files (Markdown, etc.) into strongly typed `Item` objects +3. **Processors** transform items with custom logic and filtering +4. **Writers** generate output files using various rendering contexts +5. **afterWrite hooks** run post-build steps (e.g. search indexing) ### Core Components @@ -71,6 +73,8 @@ Saga is designed for extensibility via external packages: ### Build Helpers +- `beforeRead(_:)`: Hook that runs before the read phase of each build cycle (e.g. CSS compilation). Runs on every rebuild under `saga dev`. +- `afterWrite(_:)`: Hook that runs after the write phase of each build cycle (e.g. search indexing). Runs on every rebuild under `saga dev`. - `Saga.hashed(_:)`: Static method for cache-busting asset URLs (e.g. `Saga.hashed("/static/style.css")` → `/static/style-a1b2c3d4.css`). Skipped in dev mode. - `postProcess(_:)`: Apply transforms (e.g. HTML minification) to every written file. - `Saga.sitemap(baseURL:filter:)`: Built-in renderer that generates an XML sitemap from `generatedPages`. Use with `createPage`. diff --git a/Sources/Saga/Saga.docc/AdvancedUsage.md b/Sources/Saga/Saga.docc/AdvancedUsage.md index ebef26e..9937ac9 100644 --- a/Sources/Saga/Saga.docc/AdvancedUsage.md +++ b/Sources/Saga/Saga.docc/AdvancedUsage.md @@ -2,6 +2,28 @@ Tips and techniques for more complex Saga setups. + +## Build hooks + +Use ``Saga/beforeRead(_:)`` and ``Saga/afterWrite(_:)`` to run custom logic before or after each build cycle. These hooks run on every build, including rebuilds triggered by `saga dev`. + +```swift +try await Saga(input: "content", output: "deploy") + .beforeRead { saga in + // Runs before the read phase, e.g. compile CSS + } + .register(/* ... */) + .afterWrite { saga in + // Runs after the write phase, e.g. index for search + } + .run() +``` + +You can register multiple hooks of the same type — they run in the order they were added. Each hook receives the ``Saga`` instance. + +> Tip: See for a `beforeRead` example and for an `afterWrite` example. + + ## Item processors Use an `itemProcessor` to modify items after they are read but before they are written. This is useful for transforming titles, adjusting dates, setting metadata, or any per-item logic. diff --git a/Sources/Saga/Saga.docc/Architecture.md b/Sources/Saga/Saga.docc/Architecture.md index 5450377..da89b8b 100644 --- a/Sources/Saga/Saga.docc/Architecture.md +++ b/Sources/Saga/Saga.docc/Architecture.md @@ -6,10 +6,11 @@ An overview of how Saga works. ## Overview Saga does its work in multiple stages. -1. First, it finds all the files within the `input` folder. -2. Then, for every registered step, it passes those files to a matching ``Reader``. These readers turn text files (such as markdown files) into `Item` instances. +1. Any registered ``Saga/beforeRead(_:)`` hooks run first — use these for pre-build steps like CSS compilation. +2. For every registered step, Saga passes the input files to a matching ``Reader``. These readers turn text files (such as markdown files) into `Item` instances. 3. Saga runs all the registered steps again, now executing the ``Writer``s. These writers turn a rendering context (which holds the ``Item`` among other things) into a `String` using a "renderer", which it'll then write to disk, to the `output` folder. -4. Finally, all unhandled files (images, CSS, raw HTML, etc.) are copied as-is to the `output` folder. +4. Any registered ``Saga/afterWrite(_:)`` hooks run last — use these for post-build steps like search indexing. +5. Finally, all unhandled files (images, CSS, raw HTML, etc.) are copied as-is to the `output` folder. Saga does not come with any readers or renderers out of the box. The official recommendation is to use [SagaParsleyMarkdownReader](https://github.com/loopwerk/SagaParsleyMarkdownReader) for reading markdown files using [Parsley](https://github.com/loopwerk/Parsley), and [SagaSwimRenderer](https://github.com/loopwerk/SagaSwimRenderer) to render them using [Swim](https://github.com/robb/Swim), which offers a great HTML DSL using Swift's result builders. diff --git a/Sources/Saga/Saga.docc/Guides/AddingSearch.md b/Sources/Saga/Saga.docc/Guides/AddingSearch.md index 2c1246d..908308f 100644 --- a/Sources/Saga/Saga.docc/Guides/AddingSearch.md +++ b/Sources/Saga/Saga.docc/Guides/AddingSearch.md @@ -30,7 +30,7 @@ $ pnpm add pagefind ## Run Pagefind after the build -After Saga's `run()` completes, shell out to Pagefind to index the output folder: +Use ``Saga/afterWrite(_:)`` to run Pagefind after each build: ```swift import Foundation @@ -42,17 +42,17 @@ try await Saga(input: "content", output: "deploy") readers: [.parsleyMarkdownReader], writers: [.itemWriter(swim(renderArticle))] ) + .afterWrite { _ in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["pnpm", "pagefind", "--site", "deploy"] + try process.run() + process.waitUntilExit() + } .run() - -// Index the site -let process = Process() -process.executableURL = URL(fileURLWithPath: "/usr/bin/env") -process.arguments = ["pnpm", "pagefind", "--site", "deploy"] -try process.run() -process.waitUntilExit() ``` -Pagefind generates its index and UI files into `deploy/pagefind/`. +The `afterWrite` hook runs after every build cycle, including rebuilds triggered by `saga dev`. Pagefind generates its index and UI files into `deploy/pagefind/`. ## Create a search page diff --git a/Sources/Saga/Saga.docc/Guides/TailwindCSS.md b/Sources/Saga/Saga.docc/Guides/TailwindCSS.md index 5a5a3fa..9c6de01 100644 --- a/Sources/Saga/Saga.docc/Guides/TailwindCSS.md +++ b/Sources/Saga/Saga.docc/Guides/TailwindCSS.md @@ -20,23 +20,27 @@ Place your source CSS at `content/static/input.css`: @import "tailwindcss"; ``` -Then run Tailwind before Saga: +Then use ``Saga/beforeRead(_:)`` to run Tailwind before each build: ```swift import SwiftTailwind let tailwind = SwiftTailwind(version: "4.2.1") -try await tailwind.run( - input: "content/static/input.css", - output: "content/static/output.css", - options: .minify -) try await Saga(input: "content", output: "deploy") + .beforeRead { _ in + try await tailwind.run( + input: "content/static/input.css", + output: "content/static/output.css", + options: .minify + ) + } .register(/* ... */) .run() ``` +The `beforeRead` hook runs before every build cycle, including rebuilds triggered by `saga dev`. This keeps your CSS up to date as you edit templates. + Since `output.css` is written into the `content` folder, Saga copies it to the `deploy` folder automatically. ## Option 2: Shell command @@ -47,23 +51,24 @@ If you prefer to manage Tailwind via npm, install it in your project: $ npm install tailwindcss @tailwindcss/cli ``` -Then run the CLI before Saga: +Then use ``Saga/beforeRead(_:)`` to run the CLI before each build: ```swift import Foundation -let process = Process() -process.executableURL = URL(fileURLWithPath: "/usr/bin/env") -process.arguments = [ - "npx", "@tailwindcss/cli", - "-i", "content/static/input.css", - "-o", "content/static/output.css", - "--minify", -] -try process.run() -process.waitUntilExit() - try await Saga(input: "content", output: "deploy") + .beforeRead { _ in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "npx", "@tailwindcss/cli", + "-i", "content/static/input.css", + "-o", "content/static/output.css", + "--minify", + ] + try process.run() + process.waitUntilExit() + } .register(/* ... */) .run() ``` From 4c2c23d1ab2ecbff228b2b2aec235248a76c11fe Mon Sep 17 00:00:00 2001 From: Kevin Renskers Date: Sat, 21 Mar 2026 20:11:22 +0100 Subject: [PATCH 3/3] feat: The FolderMonitor has moved from saga-cli to Saga itself This means that your site, which already knows the input and output folders, can rebuild itself. --- CLAUDE.md | 17 ++- Sources/Saga/FolderMonitor.swift | 116 ++++++++++++++++++ Sources/Saga/Saga+Build.swift | 108 +++++++++++++++++ Sources/Saga/Saga+Private.swift | 35 ++++-- Sources/Saga/Saga+Utils.swift | 5 + Sources/Saga/Saga.docc/GettingStarted.md | 15 ++- Sources/Saga/Saga.docc/Guides/TailwindCSS.md | 12 +- Sources/Saga/Saga.docc/Installation.md | 12 +- Sources/Saga/Saga.docc/Migrate.md | 84 +++++++++++++ Sources/Saga/Saga.docc/Saga.md | 1 + Sources/Saga/Saga.swift | 120 +++++-------------- 11 files changed, 403 insertions(+), 122 deletions(-) create mode 100644 Sources/Saga/FolderMonitor.swift create mode 100644 Sources/Saga/Saga+Build.swift create mode 100644 Sources/Saga/Saga.docc/Migrate.md diff --git a/CLAUDE.md b/CLAUDE.md index 49ed1e1..d2fc4cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,16 +30,13 @@ saga build # Start dev server with file watching and auto-reload saga dev -# Dev server with custom options -saga dev --watch content --watch Sources --output deploy --port 3000 - -# Ignore patterns -saga dev --ignore "*.tmp" --ignore "drafts/*" +# Dev server with custom port +saga dev --port 8080 ``` -The `saga` CLI is built from `Sources/SagaCLI/`. Install via Homebrew (`brew install loopwerk/tap/saga`) or Mint (`mint install loopwerk/Saga`). +The `saga` CLI lives in a [separate repository](https://github.com/loopwerk/saga-cli). Install via Homebrew (`brew install loopwerk/tap/saga`) or Mint (`mint install loopwerk/saga-cli`). -The legacy `watch` command (`Sources/SagaWatch/`) is deprecated in favor of `saga dev`. +File watching and ignore patterns are handled by Saga itself (not the CLI). Use `.ignore()` in your Swift code to exclude files from triggering rebuilds. ## Architecture Overview @@ -79,13 +76,13 @@ Saga is designed for extensibility via external packages: - `postProcess(_:)`: Apply transforms (e.g. HTML minification) to every written file. - `Saga.sitemap(baseURL:filter:)`: Built-in renderer that generates an XML sitemap from `generatedPages`. Use with `createPage`. - `Saga.atomFeed(title:author:baseURL:...)`: Built-in renderer that generates an Atom feed from items. -- `Saga.isDev`: `true` when running under `saga dev` or the legacy `watch` command (checks `SAGA_DEV` env var). +- `Saga.isDev`: `true` when the `SAGA_DEV` environment variable is set. Use to skip expensive work during development. +- `Saga.isCLI`: `true` when launched by saga-cli (checks `SAGA_CLI` env var). Used internally to activate file watching and rebuild loop. +- `ignore(_:)`: Add glob patterns for files that should not trigger a dev rebuild (e.g. generated CSS). ## Key Directories - `/Sources/Saga/` - Main library with core architecture -- `/Sources/SagaCLI/` - `saga` CLI (init, dev, build commands) -- `/Sources/SagaWatch/` - Legacy `watch` command (deprecated) - `/Tests/SagaTests/` - Unit tests with mock implementations - `/Example/` - Complete working example demonstrating usage patterns - `/Sources/Saga/Saga.docc/` - DocC documentation source diff --git a/Sources/Saga/FolderMonitor.swift b/Sources/Saga/FolderMonitor.swift new file mode 100644 index 0000000..255f6ee --- /dev/null +++ b/Sources/Saga/FolderMonitor.swift @@ -0,0 +1,116 @@ +import Foundation + +class FolderMonitor { + private let callback: (Set) -> Void + private let ignoredPatterns: [String] + private let basePath: String + private let paths: [String] + private var knownFiles: [String: Date] = [:] + private var timer: DispatchSourceTimer? + + init(paths: [String], ignoredPatterns: [String] = [], folderDidChange: @escaping (Set) -> Void) { + self.paths = paths + callback = folderDidChange + self.ignoredPatterns = ignoredPatterns + basePath = FileManager.default.currentDirectoryPath + + // Take initial snapshot + knownFiles = scanFiles() + + // Poll for changes every second + let timer = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "Saga.FolderMonitor")) + timer.schedule(deadline: .now() + 1, repeating: 1.0) + timer.setEventHandler { [weak self] in + self?.checkForChanges() + } + timer.resume() + self.timer = timer + } + + private func checkForChanges() { + let currentFiles = scanFiles() + + var changedPaths: Set = [] + + // Check for new or modified files + for (path, modDate) in currentFiles { + if let previousDate = knownFiles[path] { + if modDate > previousDate { + changedPaths.insert(path) + } + } else { + // New file + changedPaths.insert(path) + } + } + + // Check for deleted files + for path in knownFiles.keys { + if currentFiles[path] == nil { + changedPaths.insert(path) + } + } + + if !changedPaths.isEmpty { + knownFiles = currentFiles + callback(changedPaths) + } + } + + private func scanFiles() -> [String: Date] { + let fileManager = FileManager.default + var result: [String: Date] = [:] + + for watchPath in paths { + guard let enumerator = fileManager.enumerator(atPath: watchPath) else { continue } + + while let relativePath = enumerator.nextObject() as? String { + let fullPath = watchPath + "/" + relativePath + + var isDir: ObjCBool = false + guard fileManager.fileExists(atPath: fullPath, isDirectory: &isDir), !isDir.boolValue else { + continue + } + + if shouldIgnore(path: fullPath) { + continue + } + + if let attributes = try? fileManager.attributesOfItem(atPath: fullPath), + let modDate = attributes[.modificationDate] as? Date + { + result[fullPath] = modDate + } + } + } + + return result + } + + private func shouldIgnore(path: String) -> Bool { + guard !ignoredPatterns.isEmpty else { return false } + + let relativePath: String = if path.hasPrefix(basePath) { + String(path.dropFirst(basePath.count + 1)) + } else { + path + } + + for pattern in ignoredPatterns { + if fnmatch(pattern, relativePath, FNM_PATHNAME) == 0 { + return true + } + if let filename = relativePath.split(separator: "/").last { + if fnmatch(pattern, String(filename), 0) == 0 { + return true + } + } + } + + return false + } + + deinit { + timer?.cancel() + } +} diff --git a/Sources/Saga/Saga+Build.swift b/Sources/Saga/Saga+Build.swift new file mode 100644 index 0000000..0cb8877 --- /dev/null +++ b/Sources/Saga/Saga+Build.swift @@ -0,0 +1,108 @@ +import Foundation +import SagaPathKit + +extension Saga { + /// Run the build pipeline once. + func build() async throws { + let totalStart = DispatchTime.now() + log("Starting run") + + if !beforeReadHooks.isEmpty { + let start = DispatchTime.now() + for hook in beforeReadHooks { + try await hook(self) + } + + log("Finished beforeRead hooks in \(elapsed(from: start))") + } + + // Run all the readers for all the steps sequentially to ensure proper order, + // which turns raw content into Items, and stores them within the step. + let readStart = DispatchTime.now() + for step in steps { + let items = try await step.read(self) + allItems.append(contentsOf: items) + } + + log("Finished read phase in \(elapsed(from: readStart))") + + // Sort all items by date descending + allItems.sort { $0.date > $1.date } + + // Clean the output folder + try fileIO.deletePath(outputPath) + + // Copy all unhandled files as-is to the output folder first, + // so that the directory structure exists for the write phase. + let copyStart = DispatchTime.now() + try await withThrowingTaskGroup(of: Void.self) { group in + for file in unhandledFiles { + group.addTask { + let output = self.outputPath + file.relativePath + try self.fileIO.mkpath(output.parent()) + try self.fileIO.copy(file.path, output) + } + } + try await group.waitForAll() + } + + log("Finished copying static files in \(elapsed(from: copyStart))") + + // Make Saga.hashed() work + setupHashFunction() + + // Run all writers sequentially + // processedWrite tracks generated paths automatically. + let writeStart = DispatchTime.now() + for step in steps { + try await step.write(self) + } + + log("Finished write phase in \(elapsed(from: writeStart))") + + // Copy hashed versions of files that were referenced via Saga.hashed() + try copyHashedFiles() + + if !afterWriteHooks.isEmpty { + let start = DispatchTime.now() + for hook in afterWriteHooks { + try await hook(self) + } + + log("Finished afterWrite hooks in \(elapsed(from: start))") + } + + log("All done in \(elapsed(from: totalStart))") + } + + /// Watch for file changes and rebuild when content changes. + /// Exits with code 42 if Swift source files change (signals saga-cli to recompile). + func watchAndRebuild() async throws { + let watchPaths = [inputPath.string, (rootPath + "Sources").string] + let monitor = FolderMonitor(paths: watchPaths, ignoredPatterns: ignoredPatterns) { [weak self] changedPaths in + guard let self else { return } + + if changedPaths.contains(where: { $0.hasSuffix(".swift") }) { + // Swift source changed — exit so saga-cli can recompile and relaunch + exit(42) + } + + Task { + do { + try self.reset() + try await self.build() + self.signalParent() + } catch { + print("Rebuild failed: \(error)") + } + } + } + + // Keep the monitor alive and block forever (dev mode runs until killed) + withExtendedLifetime(monitor) { + while true { + Thread.sleep(forTimeInterval: .greatestFiniteMagnitude) + } + } + } +} diff --git a/Sources/Saga/Saga+Private.swift b/Sources/Saga/Saga+Private.swift index 8670d75..14a19bf 100644 --- a/Sources/Saga/Saga+Private.swift +++ b/Sources/Saga/Saga+Private.swift @@ -1,6 +1,11 @@ import Foundation import SagaPathKit +struct SagaConfig: Codable { + let input: String + let output: String +} + private struct FileReadResult { let filePath: Path let item: Item? @@ -47,6 +52,24 @@ extension Saga { return result } + /// Signal the parent process (saga-cli) that a build completed, so it can send a browser reload. + func signalParent() { + kill(getppid(), SIGUSR2) + } + + /// Write a config file to `.build/saga-config.json` so saga-cli can detect output path for serving. + func writeConfigFile() { + let config = SagaConfig( + input: (try? inputPath.relativePath(from: rootPath))?.string ?? "content", + output: (try? outputPath.relativePath(from: rootPath))?.string ?? "deploy" + ) + + let configPath = rootPath + ".build/saga-config.json" + if let data = try? JSONEncoder().encode(config) { + try? data.write(to: URL(fileURLWithPath: configPath.string)) + } + } + /// Reset mutable pipeline state between dev rebuilds. func reset() throws { allItems = [] @@ -62,18 +85,6 @@ extension Saga { } } - /// Wait for SIGUSR1, then return. - func waitForSignal() async { - await withCheckedContinuation { continuation in - let source = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .main) - source.setEventHandler { - source.cancel() - continuation.resume() - } - source.resume() - } - } - /// Read files from disk using a Reader, turns them into Items func readItems( folder: Path?, diff --git a/Sources/Saga/Saga+Utils.swift b/Sources/Saga/Saga+Utils.swift index af4ae1a..5321b3f 100644 --- a/Sources/Saga/Saga+Utils.swift +++ b/Sources/Saga/Saga+Utils.swift @@ -63,6 +63,11 @@ public extension Saga { ProcessInfo.processInfo.environment["SAGA_DEV"] != nil } + /// Whether the site was launched by saga-cli (the `SAGA_CLI` environment variable is set). + static var isCLI: Bool { + ProcessInfo.processInfo.environment["SAGA_CLI"] != nil + } + /// A renderer which creates an HTML page that redirects the browser to the given URL. /// /// Uses both a `` tag and a canonical link for diff --git a/Sources/Saga/Saga.docc/GettingStarted.md b/Sources/Saga/Saga.docc/GettingStarted.md index 6e61eed..ce8177b 100644 --- a/Sources/Saga/Saga.docc/GettingStarted.md +++ b/Sources/Saga/Saga.docc/GettingStarted.md @@ -167,16 +167,19 @@ From your website folder you can run the following command to start a developmen $ saga dev ``` -By default this watches the `content` and `Sources` folders, outputs to `deploy`, and serves on port 3000. All of these can be customized: +Saga automatically watches your content folder and `Sources/` for changes. Content changes trigger an in-process rebuild; Swift source changes trigger a recompilation. The dev server runs on port 3000 by default: ```shell-session -$ saga dev --watch content --watch Sources --output deploy --port 3000 +$ saga dev --port 8080 ``` -You can also ignore certain files or folders using glob patterns: +To prevent certain files from triggering rebuilds (e.g. generated CSS), use ``Saga/ignore(_:)`` in your Swift code: -```shell-session -$ saga dev --ignore "*.tmp" --ignore "drafts/*" +```swift +try await Saga(input: "content", output: "deploy") + .ignore("output.css") + .register(/* ... */) + .run() ``` To just build the site without starting a server: @@ -185,6 +188,6 @@ To just build the site without starting a server: $ saga build ``` -When running under `saga dev`, Saga sets the `SAGA_DEV` environment variable and exposes it as ``Saga/isDev``. Use this to skip expensive work during development, such as image generation or HTML minification. +When running under `saga dev`, ``Saga/isDev`` is `true`. Use this to skip expensive work during development, such as image generation or HTML minification. See for how to install the `saga` CLI. diff --git a/Sources/Saga/Saga.docc/Guides/TailwindCSS.md b/Sources/Saga/Saga.docc/Guides/TailwindCSS.md index 9c6de01..ea64ead 100644 --- a/Sources/Saga/Saga.docc/Guides/TailwindCSS.md +++ b/Sources/Saga/Saga.docc/Guides/TailwindCSS.md @@ -75,10 +75,16 @@ try await Saga(input: "content", output: "deploy") ## With `saga dev` -Because `output.css` is written into the `content` folder, the dev server's file watcher will detect the change and trigger a rebuild. Which then regenerates `output.css`, which triggers another rebuild, and so on. Break this loop by telling `saga dev` to ignore the generated file: +Because `output.css` is written into the `content` folder, the file watcher will detect the change and trigger a rebuild. Which then regenerates `output.css`, which triggers another rebuild, and so on. Break this loop by telling Saga to ignore the generated file: -```shell-session -$ saga dev --ignore output.css +```swift +try await Saga(input: "content", output: "deploy") + .ignore("output.css") + .beforeRead { _ in + try await tailwind.run(/* ... */) + } + .register(/* ... */) + .run() ``` ## Cache-busting diff --git a/Sources/Saga/Saga.docc/Installation.md b/Sources/Saga/Saga.docc/Installation.md index 65af42b..e32e0fe 100644 --- a/Sources/Saga/Saga.docc/Installation.md +++ b/Sources/Saga/Saga.docc/Installation.md @@ -17,6 +17,16 @@ $ brew install loopwerk/tap/saga $ mint install loopwerk/saga-cli ``` +**From source:** + +```shell-session +$ git clone https://github.com/loopwerk/saga-cli.git +$ cd saga-cli +$ swift package experimental-install +``` + +This installs the `saga` binary to `~/.swiftpm/bin`. Make sure that directory is in your `PATH`. + ## Quick start @@ -46,7 +56,7 @@ let package = Package( .macOS(.v14) ], dependencies: [ - .package(url: "https://github.com/loopwerk/Saga", from: "2.0.0"), + .package(url: "https://github.com/loopwerk/Saga", from: "3.0.0"), .package(url: "https://github.com/loopwerk/SagaParsleyMarkdownReader", from: "1.0.0"), .package(url: "https://github.com/loopwerk/SagaSwimRenderer", from: "1.0.0"), ], diff --git a/Sources/Saga/Saga.docc/Migrate.md b/Sources/Saga/Saga.docc/Migrate.md new file mode 100644 index 0000000..c219b10 --- /dev/null +++ b/Sources/Saga/Saga.docc/Migrate.md @@ -0,0 +1,84 @@ +# Migrating to Saga 3 + +How to update your project from Saga 2 to Saga 3. + + +## Update your dependencies + +In your `Package.swift`, bump the Saga dependency: + +```swift +// Before +.package(url: "https://github.com/loopwerk/Saga", from: "2.0.0"), + +// After +.package(url: "https://github.com/loopwerk/Saga", from: "3.0.0"), +``` + +Saga 3 requires Swift 6.0 and macOS 14 or Linux. + + +## Update saga-cli + +saga-cli 2.x unlocks incremental rebuilds and faster dev cycles. Update via Homebrew: + +```shell-session +$ brew upgrade loopwerk/tap/saga +``` + +Or via Mint: + +```shell-session +$ mint install loopwerk/saga-cli +``` + +If you installed from source, pull the latest and reinstall: + +```shell-session +$ cd path/to/saga-cli +$ git pull +$ swift package experimental-install +``` + +### Removed CLI flags + +The `--watch`, `--output`, and `--ignore` flags have been removed from `saga dev`. Saga now handles file watching and detects the folders from your Swift code. The only remaining option is `--port`. + +If you were using `--ignore`, use ``Saga/ignore(_:)`` in your Swift code instead: + +```swift +// Before (saga-cli 1.x) +// $ saga dev --ignore output.css + +// After (Saga 3) +try await Saga(input: "content", output: "deploy") + .ignore("output.css") + .register(/* ... */) + .run() +``` + + +## New: build hooks + +Saga 3 adds ``Saga/beforeRead(_:)`` and ``Saga/afterWrite(_:)`` hooks that run before and after each build cycle, including incremental builds during `saga dev`. + +These are useful for pre-build steps like CSS compilation and post-build steps like search indexing: + +```swift +try await Saga(input: "content", output: "deploy") + .beforeRead { _ in + // e.g. compile Tailwind CSS + } + .register(/* ... */) + .afterWrite { _ in + // e.g. run Pagefind + } + .run() +``` + +See and for examples. + + +## New: incremental dev rebuilds + +When running under `saga dev`, Saga 3 stays alive between rebuilds and caches the read phase. Unchanged files are not re-parsed, making content rebuilds significantly faster. This happens automatically; no code changes needed. diff --git a/Sources/Saga/Saga.docc/Saga.md b/Sources/Saga/Saga.docc/Saga.md index 2770cda..99175d9 100644 --- a/Sources/Saga/Saga.docc/Saga.md +++ b/Sources/Saga/Saga.docc/Saga.md @@ -24,6 +24,7 @@ You can read [this series of articles](https://www.loopwerk.io/articles/tag/saga - - - +- ### Guides diff --git a/Sources/Saga/Saga.swift b/Sources/Saga/Saga.swift index 86854b5..d228847 100644 --- a/Sources/Saga/Saga.swift +++ b/Sources/Saga/Saga.swift @@ -49,6 +49,9 @@ public class Saga: StepBuilder, @unchecked Sendable { /// In-memory reader cache that survives between dev rebuilds var readerCache: [String: [String: AnyItem]] = [:] + /// Glob patterns to ignore during file watching in dev mode + var ignoredPatterns: [String] = [".DS_Store"] + public init(input: Path, output: Path = "deploy", fileIO: FileIO = .diskAccess, originFilePath: StaticString = #filePath) throws { let originFile = Path("\(originFilePath)") let rootPath = try fileIO.resolveSwiftPackageFolder(originFile) @@ -124,100 +127,37 @@ public class Saga: StepBuilder, @unchecked Sendable { return self } + /// Add a glob pattern to ignore during file watching in dev mode. + /// + /// Use this to prevent unnecessary rebuilds when certain files change: + /// ```swift + /// try await Saga(input: "content", output: "deploy") + /// .ignore("output.css") + /// .ignore("*.tmp") + /// .register(...) + /// .run() + /// ``` + @discardableResult + public func ignore(_ pattern: String) -> Self { + ignoredPatterns.append(pattern) + return self + } + /// Execute all the registered steps. @discardableResult public func run() async throws -> Self { - // In dev mode, ignore SIGUSR1 at the process level so DispatchSource can handle it - if Saga.isDev { - signal(SIGUSR1, SIG_IGN) + // Write config file so saga-cli can detect output path for serving + writeConfigFile() + + // Run the pipeline + try await build() + + // When launched by `saga dev`, watch for changes and rebuild + if Saga.isDev, Saga.isCLI { + signalParent() + try await watchAndRebuild() } - while true { - let totalStart = DispatchTime.now() - log("Starting run") - - if !beforeReadHooks.isEmpty { - let start = DispatchTime.now() - for hook in beforeReadHooks { - try await hook(self) - } - - log("Finished beforeRead hooks in \(elapsed(from: start))") - } - - // Run all the readers for all the steps sequentially to ensure proper order, - // which turns raw content into Items, and stores them within the step. - let readStart = DispatchTime.now() - for step in steps { - let items = try await step.read(self) - allItems.append(contentsOf: items) - } - - log("Finished read phase in \(elapsed(from: readStart))") - - // Sort all items by date descending - allItems.sort { $0.date > $1.date } - - // Clean the output folder - try fileIO.deletePath(outputPath) - - // Copy all unhandled files as-is to the output folder first, - // so that the directory structure exists for the write phase. - let copyStart = DispatchTime.now() - try await withThrowingTaskGroup(of: Void.self) { group in - for file in unhandledFiles { - group.addTask { - let output = self.outputPath + file.relativePath - try self.fileIO.mkpath(output.parent()) - try self.fileIO.copy(file.path, output) - } - } - try await group.waitForAll() - } - - log("Finished copying static files in \(elapsed(from: copyStart))") - - // Make Saga.hashed() work - setupHashFunction() - - // Run all writers sequentially - // processedWrite tracks generated paths automatically. - let writeStart = DispatchTime.now() - for step in steps { - try await step.write(self) - } - - log("Finished write phase in \(elapsed(from: writeStart))") - - // Copy hashed versions of files that were referenced via Saga.hashed() - try copyHashedFiles() - - if !afterWriteHooks.isEmpty { - let start = DispatchTime.now() - for hook in afterWriteHooks { - try await hook(self) - } - - log("Finished afterWrite hooks in \(elapsed(from: start))") - } - - log("All done in \(elapsed(from: totalStart))") - - // In dev mode, signal the CLI that the build is done, then wait for SIGUSR1 - if Saga.isDev { - // Signal the parent process (saga dev) that the build completed - if let pidString = ProcessInfo.processInfo.environment["SAGA_DEV_PID"], - let pid = Int32(pidString) - { - kill(pid, SIGUSR2) - } - - await waitForSignal() - try reset() - continue - } - - return self - } // while true + return self } }