Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 15 additions & 14 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,24 +30,23 @@ 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

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<M: Metadata>` 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<M: Metadata>` 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

Expand All @@ -71,17 +70,19 @@ 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`.
- `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
Expand Down
116 changes: 116 additions & 0 deletions Sources/Saga/FolderMonitor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Foundation

class FolderMonitor {
private let callback: (Set<String>) -> 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<String>) -> 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<String> = []

// 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()
}
}
2 changes: 1 addition & 1 deletion Sources/Saga/Item.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ public class Item<M: Metadata>: 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<C: Metadata>(as type: C.Type) -> [Item<C>] {
Expand Down
108 changes: 108 additions & 0 deletions Sources/Saga/Saga+Build.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Loading