diff --git a/CLAUDE.md b/CLAUDE.md index 063605a..e24f021 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,19 +14,33 @@ swift test # Run the example project swift run -# Start development server with file watching -swift run watch --watch content --watch Sources --output deploy +# Generate documentation +./generate_docs.sh +``` -# With ignore patterns -swift run watch --watch content --output deploy --ignore "*.tmp" +## CLI Commands (`saga`) -# Legacy syntax (still supported) -swift run watch content Sources deploy +```bash +# Create a new Saga project +saga init mysite -# Generate documentation -./generate_docs.sh +# Build a site (runs `swift run` in current directory) +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/*" ``` +The `saga` CLI is built from `Sources/SagaCLI/`. Install via Homebrew (`brew install loopwerk/tap/saga`) or Mint (`mint install loopwerk/Saga`). + +The legacy `watch` command (`Sources/SagaWatch/`) is deprecated in favor of `saga dev`. + ## Architecture Overview Saga is a static site generator written in Swift that follows a **Reader → Processor → Writer** pipeline pattern: @@ -58,7 +72,8 @@ Saga is designed for extensibility via external packages: ## Key Directories - `/Sources/Saga/` - Main library with core architecture -- `/Sources/SagaCLI/` - Command line interface with development server +- `/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/Example/Package.resolved b/Example/Package.resolved index 50194fc..e700f7a 100644 --- a/Example/Package.resolved +++ b/Example/Package.resolved @@ -73,6 +73,15 @@ "version": "1.7.0" } }, + { + "package": "swift-atomics", + "repositoryURL": "https://github.com/apple/swift-atomics.git", + "state": { + "branch": null, + "revision": "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version": "1.3.0" + } + }, { "package": "swift-cmark-gfm", "repositoryURL": "https://github.com/stackotter/swift-cmark-gfm", @@ -82,6 +91,33 @@ "version": "1.0.2" } }, + { + "package": "swift-collections", + "repositoryURL": "https://github.com/apple/swift-collections.git", + "state": { + "branch": null, + "revision": "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version": "1.3.0" + } + }, + { + "package": "swift-nio", + "repositoryURL": "https://github.com/apple/swift-nio", + "state": { + "branch": null, + "revision": "9b92dcd5c22ae17016ad867852e0850f1f9f93ed", + "version": "2.94.1" + } + }, + { + "package": "swift-system", + "repositoryURL": "https://github.com/apple/swift-system.git", + "state": { + "branch": null, + "revision": "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version": "1.6.4" + } + }, { "package": "HTML", "repositoryURL": "https://github.com/robb/Swim", diff --git a/Example/Sources/Example/String+Extensions.swift b/Example/Sources/Example/String+Extensions.swift index 91cbbfb..a29e2bb 100644 --- a/Example/Sources/Example/String+Extensions.swift +++ b/Example/Sources/Example/String+Extensions.swift @@ -7,8 +7,8 @@ extension String { return components.filter { !$0.isEmpty }.count } - // This is a sloppy implementation but sadly `NSAttributedString(data:options:documentAttributes:)` - // is not available in CoreFoundation, and as such can't run on Linux (blocking CI builds). + /// This is a sloppy implementation but sadly `NSAttributedString(data:options:documentAttributes:)` + /// is not available in CoreFoundation, and as such can't run on Linux (blocking CI builds). var withoutHtmlTags: String { return replacingOccurrences(of: "(?m)
[\\s\\S]+?
", with: "", options: .regularExpression, range: nil) .replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression, range: nil) diff --git a/Example/Sources/Example/run.swift b/Example/Sources/Example/run.swift index 5ed6e05..67a16b6 100644 --- a/Example/Sources/Example/run.swift +++ b/Example/Sources/Example/run.swift @@ -24,7 +24,7 @@ struct AppMetadata: Metadata { struct AlbumMetadata: Metadata {} struct PhotoMetadata: Metadata {} -// An easy way to only get public articles, since ArticleMetadata.public is optional +/// An easy way to only get public articles, since ArticleMetadata.public is optional extension Item where M == ArticleMetadata { var `public`: Bool { return metadata.public ?? true @@ -96,7 +96,6 @@ struct Run { // Run the steps we registered above .run() - // All the remaining files that were not parsed to markdown, so for example images, raw html files and css, // are copied as-is to the output folder. .staticFiles() diff --git a/Example/Sources/Example/templates.swift b/Example/Sources/Example/templates.swift index 53bbfab..3360667 100644 --- a/Example/Sources/Example/templates.swift +++ b/Example/Sources/Example/templates.swift @@ -1,9 +1,9 @@ import Foundation import HTML import Moon +import PathKit import Saga import SagaSwimRenderer -import PathKit // MARK: - Helpers @@ -172,13 +172,13 @@ func renderAlbums(context: ItemsRenderingContext) -> Node { baseHtml(title: "Photos") { div(class: "collections") { h1 { "Photos" } - + div(class: "collections-grid") { context.items.map { album in let photos = photosForAlbum(album, allItems: context.allItems) let previewPhotos = Array(photos.prefix(4)) let folder = album.relativeSource.parent() - + return a(class: "collection-card", href: album.url) { div(class: "card-previews") { previewPhotos.map { photo in diff --git a/Package.swift b/Package.swift index bfae41e..95e5ff5 100644 --- a/Package.swift +++ b/Package.swift @@ -9,11 +9,13 @@ let package = Package( ], products: [ .library(name: "Saga", targets: ["Saga"]), - .executable(name: "watch", targets: ["SagaCLI"]), + .executable(name: "saga", targets: ["SagaCLI"]), + .executable(name: "watch", targets: ["SagaWatch"]), ], dependencies: [ .package(url: "https://github.com/kylef/PathKit", from: "1.0.1"), .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-nio", from: "2.65.0"), // .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0"), ], targets: [ @@ -23,6 +25,16 @@ let package = Package( ), .executableTarget( name: "SagaCLI", + dependencies: [ + "PathKit", + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + ] + ), + .executableTarget( + name: "SagaWatch", dependencies: [ "PathKit", .product(name: "ArgumentParser", package: "swift-argument-parser"), diff --git a/README.md b/README.md index 5663fa1..efd13aa 100644 --- a/README.md +++ b/README.md @@ -33,14 +33,36 @@ Typed metadata, pluggable readers, multiple writer types, pagination, tags — a - You've outgrown convention-based SSGs and want full control **Saga is not for you if:** -- You want a CLI that scaffolds everything with zero code - You need a large ecosystem of themes and templates - You're not comfortable with Swift +## Installing the CLI + +**Via [Homebrew](https://brew.sh):** +``` +$ brew install loopwerk/tap/saga +``` + +**Via [Mint](https://github.com/yonaskolb/Mint):** +``` +$ mint install loopwerk/Saga +``` + + ## Getting started -Getting started takes a few minutes if you're comfortable with Swift and SwiftPM. Follow the [installation guide](https://loopwerk.github.io/Saga/documentation/saga/installation) to set up your first site. +The fastest way to start a new site: + +``` +$ saga init mysite +$ cd mysite +$ saga dev +``` + +This scaffolds a complete project with articles, tags, templates, and a stylesheet — ready to build and serve. + +For manual setup or more detail, see the [installation guide](https://loopwerk.github.io/Saga/documentation/saga/installation). ## Code over configuration @@ -107,7 +129,7 @@ Saga is modular. You compose it with readers and renderers that fit your needs. ## Requirements -Saga requires Swift 5.5+ and runs on macOS 12+ and Linux. The development server requires [browser-sync](https://github.com/BrowserSync/browser-sync) and only works on macOS. +Saga requires Swift 5.5+ and runs on macOS 12+ and Linux. ## Websites using Saga diff --git a/Sources/Saga/MetadataDecoder.swift b/Sources/Saga/MetadataDecoder.swift index 3dfd1ab..250e80b 100644 --- a/Sources/Saga/MetadataDecoder.swift +++ b/Sources/Saga/MetadataDecoder.swift @@ -1,4 +1,4 @@ -/** +/* * Publish * Copyright (c) John Sundell 2019 * MIT license, see LICENSE file for details @@ -7,7 +7,10 @@ import Foundation final class MetadataDecoder: Decoder { - var userInfo: [CodingUserInfoKey: Any] { [:] } + var userInfo: [CodingUserInfoKey: Any] { + [:] + } + let codingPath: [CodingKey] private let metadata: [String: String] @@ -68,7 +71,9 @@ final class MetadataDecoder: Decoder { private extension MetadataDecoder { struct KeyedContainer: KeyedDecodingContainerProtocol { - var allKeys: [Key] { keys.all() } + var allKeys: [Key] { + keys.all() + } let metadata: [String: String] let keys: KeyMap @@ -246,8 +251,13 @@ private extension MetadataDecoder { } struct UnkeyedContainer: UnkeyedDecodingContainer { - var count: Int? { components.count } - var isAtEnd: Bool { currentIndex == components.endIndex } + var count: Int? { + components.count + } + + var isAtEnd: Bool { + currentIndex == components.endIndex + } let components: [Substring] let codingPath: [CodingKey] @@ -393,7 +403,9 @@ private extension MetadataDecoder { } struct UnkeyedDecoder: Decoder { - var userInfo: [CodingUserInfoKey: Any] { [:] } + var userInfo: [CodingUserInfoKey: Any] { + [:] + } let components: [Substring] var codingPath: [CodingKey] @@ -503,7 +515,9 @@ private extension MetadataDecoder { } struct SingleValueDecoder: Decoder { - var userInfo: [CodingUserInfoKey: Any] { [:] } + var userInfo: [CodingUserInfoKey: Any] { + [:] + } let value: String let codingPath: [CodingKey] diff --git a/Sources/Saga/Saga.docc/GettingStarted.md b/Sources/Saga/Saga.docc/GettingStarted.md index d2fb931..76586de 100644 --- a/Sources/Saga/Saga.docc/GettingStarted.md +++ b/Sources/Saga/Saga.docc/GettingStarted.md @@ -209,31 +209,25 @@ It's also easy to add your own readers and renderers; search for [saga-plugin](h From your website folder you can run the following command to start a development server, which rebuilds your website on changes, and reloads the browser as well. ``` -$ swift run watch --watch [folder] --output [output-folder] +$ saga dev ``` -Use the same relative input- and output folders as you gave to Saga. The `--watch` option can be specified multiple times to watch multiple folders. Example: +By default this watches the `content` and `Sources` folders, outputs to `deploy`, and serves on port 3000. All of these can be customized: ``` -$ swift run watch --watch content --watch Sources --output deploy +$ saga dev --watch content --watch Sources --output deploy --port 3000 ``` -This will rebuild whenever you change your content or your Swift code. - You can also ignore certain files or folders using glob patterns: ``` -$ swift run watch --watch content --output deploy --ignore "*.tmp" --ignore "drafts/*" +$ saga dev --ignore "*.tmp" --ignore "drafts/*" ``` -For backwards compatibility, the legacy positional argument syntax is also supported: +To just build the site without starting a server: ``` -$ swift run watch content Sources deploy +$ saga build ``` -This functionality depends on a globally installed [browser-sync](https://github.com/BrowserSync/browser-sync), and only works on macOS, not Linux. - -``` -$ pnpm install -g browser-sync -``` +See for how to install the `saga` CLI. diff --git a/Sources/Saga/Saga.docc/Installation.md b/Sources/Saga/Saga.docc/Installation.md index e26a97f..9b6e676 100644 --- a/Sources/Saga/Saga.docc/Installation.md +++ b/Sources/Saga/Saga.docc/Installation.md @@ -1,10 +1,39 @@ # Installation -How to set up your project with the right dependencies. +How to set up a new Saga project. -## Overview -Create a new folder and inside of it run `swift package init --type executable`, and then `open Package.swift`. Edit Package.swift to add the Saga dependency, plus a reader and optionally a renderer (see ), so that it looks something like this: +## Installing the CLI + +**Via [Homebrew](https://brew.sh):** + +``` +$ brew install loopwerk/tap/saga +``` + +**Via [Mint](https://github.com/yonaskolb/Mint):** + +``` +$ mint install loopwerk/Saga +``` + + +## Quick start + +The easiest way to create a new project is with the `saga` CLI: + +``` +$ saga init mysite +$ cd mysite +$ saga dev +``` + +This scaffolds a complete project with articles, tags, Swim templates, and a stylesheet. The `saga dev` command builds your site, starts a development server at `http://localhost:3000`, and auto-reloads the browser when you make changes. + + +## Manual setup + +If you prefer to set things up yourself, create a new folder and inside of it run `swift package init --type executable`, and then `open Package.swift`. Edit Package.swift to add the Saga dependency, plus a reader and optionally a renderer (see ), so that it looks something like this: ```swift // swift-tools-version:5.5 @@ -25,8 +54,8 @@ let package = Package( .executableTarget( name: "MyWebsite", dependencies: [ - "Saga", - "SagaParsleyMarkdownReader", + "Saga", + "SagaParsleyMarkdownReader", "SagaSwimRenderer" ] ) @@ -38,4 +67,4 @@ Now you can `import Saga` and use it. You can continue with the String in + if folder.hasPrefix("/") { + return folder + } + return currentPath + "/" + folder + } + + let defaultIgnorePatterns = [".DS_Store"] + + // Start monitoring + if !ignore.isEmpty { + print("Ignoring patterns: \(ignore.joined(separator: ", "))") + } + + var isRebuilding = false + let rebuildLock = NSLock() + + let folderMonitor = FolderMonitor(paths: paths, ignoredPatterns: defaultIgnorePatterns + ignore) { + rebuildLock.lock() + guard !isRebuilding else { + rebuildLock.unlock() + return + } + isRebuilding = true + rebuildLock.unlock() + + print("Change detected, rebuilding...") + let success = runBuild() + if success { + print("Rebuild complete.") + server.sendReload() + } else { + print("Rebuild failed.") + } + + rebuildLock.lock() + isRebuilding = false + rebuildLock.unlock() + } + + // Handle Ctrl+C shutdown + let signalsQueue = DispatchQueue(label: "Saga.Signals") + let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT, queue: signalsQueue) + sigintSrc.setEventHandler { + print("\nShutting down...") + server.stop() + Foundation.exit(0) + } + sigintSrc.resume() + signal(SIGINT, SIG_IGN) + + print("Watching for changes in: \(watch.joined(separator: ", "))") + + // Prevent folderMonitor from being deallocated + withExtendedLifetime(folderMonitor) { + // Keep running + dispatchMain() + } + } + + private func runBuild() -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["swift", "run"] + process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + do { + try process.run() + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + if let output = String(data: data, encoding: .utf8), !output.isEmpty { + print(output, terminator: "") + } + + return process.terminationStatus == 0 + } catch { + print("Build error: \(error)") + return false + } + } + + private func openBrowser(url: String) { + #if os(macOS) + Process.launchedProcess(launchPath: "/usr/bin/open", arguments: [url]) + #elseif os(Linux) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["xdg-open", url] + try? process.run() + #endif + } +} diff --git a/Sources/SagaCLI/DevServer.swift b/Sources/SagaCLI/DevServer.swift new file mode 100644 index 0000000..5ac0357 --- /dev/null +++ b/Sources/SagaCLI/DevServer.swift @@ -0,0 +1,249 @@ +import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix + +final class DevServer { + private let outputPath: String + private let port: Int + private let group: MultiThreadedEventLoopGroup + private var channel: Channel? + private let sseConnections = SSEConnectionStore() + + init(outputPath: String, port: Int) { + self.outputPath = outputPath + self.port = port + group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + } + + func start() throws { + let outputPath = self.outputPath + let sseConnections = self.sseConnections + let baseDir = FileManager.default.currentDirectoryPath + + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.backlog, value: 256) + .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.pipeline.configureHTTPServerPipeline().flatMap { + channel.pipeline.addHandler(HTTPHandler(outputPath: baseDir + "/" + outputPath, sseConnections: sseConnections)) + } + } + + channel = try bootstrap.bind(host: "127.0.0.1", port: port).wait() + try channel?.closeFuture.wait() + } + + func stop() { + try? channel?.close().wait() + try? group.syncShutdownGracefully() + } + + func sendReload() { + sseConnections.sendReload() + } +} + +final class SSEConnectionStore: @unchecked Sendable { + private var connections: [Channel] = [] + private let lock = NSLock() + + func add(_ channel: Channel) { + lock.lock() + connections.append(channel) + lock.unlock() + } + + func remove(_ channel: Channel) { + lock.lock() + connections.removeAll { $0 === channel } + lock.unlock() + } + + func sendReload() { + lock.lock() + let current = connections + lock.unlock() + + for channel in current { + var buffer = channel.allocator.buffer(capacity: 64) + buffer.writeString("data: reload\n\n") + channel.writeAndFlush(HTTPServerResponsePart.body(.byteBuffer(buffer)), promise: nil) + } + } +} + +private final class HTTPHandler: ChannelInboundHandler { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + private let outputPath: String + private let sseConnections: SSEConnectionStore + private var requestURI: String = "/" + + init(outputPath: String, sseConnections: SSEConnectionStore) { + self.outputPath = outputPath + self.sseConnections = sseConnections + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let part = unwrapInboundIn(data) + + switch part { + case .head(let request): + requestURI = request.uri + + case .body: + break + + case .end: + handleRequest(uri: requestURI, context: context) + } + } + + private func handleRequest(uri: String, context: ChannelHandlerContext) { + // SSE endpoint for auto-reload + if uri == "/_reload" { + handleSSE(context: context) + return + } + + // Static file serving + let filePath = resolveFilePath(uri: uri) + + guard let filePath = filePath, + FileManager.default.fileExists(atPath: filePath), + let data = FileManager.default.contents(atPath: filePath) + else { + sendNotFound(context: context) + return + } + + let contentType = mimeType(for: filePath) + let isHTML = contentType == "text/html" + + var responseData: Data + if isHTML, let html = String(data: data, encoding: .utf8) { + responseData = Data(injectReloadScript(into: html).utf8) + } else { + responseData = data + } + + var headers = HTTPHeaders() + headers.add(name: "Content-Type", value: contentType) + headers.add(name: "Content-Length", value: "\(responseData.count)") + headers.add(name: "Cache-Control", value: "no-cache") + + let head = HTTPResponseHead(version: .http1_1, status: .ok, headers: headers) + context.write(wrapOutboundOut(.head(head)), promise: nil) + + var buffer = context.channel.allocator.buffer(capacity: responseData.count) + buffer.writeBytes(responseData) + context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil) + } + + private func handleSSE(context: ChannelHandlerContext) { + var headers = HTTPHeaders() + headers.add(name: "Content-Type", value: "text/event-stream") + headers.add(name: "Cache-Control", value: "no-cache") + headers.add(name: "Connection", value: "keep-alive") + + let head = HTTPResponseHead(version: .http1_1, status: .ok, headers: headers) + context.writeAndFlush(wrapOutboundOut(.head(head)), promise: nil) + + sseConnections.add(context.channel) + + context.channel.closeFuture.whenComplete { [weak self] _ in + self?.sseConnections.remove(context.channel) + } + } + + private func resolveFilePath(uri: String) -> String? { + let path = uri.split(separator: "?").first.map(String.init) ?? uri + let fileManager = FileManager.default + + // Direct file match + let directPath = outputPath + path + if fileManager.fileExists(atPath: directPath) { + var isDir: ObjCBool = false + fileManager.fileExists(atPath: directPath, isDirectory: &isDir) + if !isDir.boolValue { + return directPath + } + // It's a directory, look for index.html + let indexPath = directPath.hasSuffix("/") ? directPath + "index.html" : directPath + "/index.html" + if fileManager.fileExists(atPath: indexPath) { + return indexPath + } + } + + // Try with .html extension + let htmlPath = directPath + ".html" + if fileManager.fileExists(atPath: htmlPath) { + return htmlPath + } + + // Try path/index.html + let indexPath = directPath.hasSuffix("/") ? directPath + "index.html" : directPath + "/index.html" + if fileManager.fileExists(atPath: indexPath) { + return indexPath + } + + return nil + } + + private func sendNotFound(context: ChannelHandlerContext) { + let body = "404 Not Found" + var headers = HTTPHeaders() + headers.add(name: "Content-Type", value: "text/plain") + headers.add(name: "Content-Length", value: "\(body.utf8.count)") + + let head = HTTPResponseHead(version: .http1_1, status: .notFound, headers: headers) + context.write(wrapOutboundOut(.head(head)), promise: nil) + + var buffer = context.channel.allocator.buffer(capacity: body.utf8.count) + buffer.writeString(body) + context.write(wrapOutboundOut(.body(.byteBuffer(buffer))), promise: nil) + context.writeAndFlush(wrapOutboundOut(.end(nil)), promise: nil) + } + + private func injectReloadScript(into html: String) -> String { + let script = """ + + """ + if let range = html.range(of: "", options: .backwards) { + return html.replacingCharacters(in: range, with: script + "") + } + return html + script + } + + private func mimeType(for path: String) -> String { + let ext: String + if let dotIndex = path.lastIndex(of: ".") { + ext = String(path[path.index(after: dotIndex)...]).lowercased() + } else { + ext = "" + } + switch ext { + case "html", "htm": return "text/html" + case "css": return "text/css" + case "js": return "application/javascript" + case "json": return "application/json" + case "xml": return "application/xml" + case "png": return "image/png" + case "jpg", "jpeg": return "image/jpeg" + case "gif": return "image/gif" + case "svg": return "image/svg+xml" + case "webp": return "image/webp" + case "ico": return "image/x-icon" + case "woff": return "font/woff" + case "woff2": return "font/woff2" + case "ttf": return "font/ttf" + case "otf": return "font/otf" + case "pdf": return "application/pdf" + case "txt": return "text/plain" + default: return "application/octet-stream" + } + } +} diff --git a/Sources/SagaCLI/FolderMonitor.swift b/Sources/SagaCLI/FolderMonitor.swift new file mode 100644 index 0000000..e43ce72 --- /dev/null +++ b/Sources/SagaCLI/FolderMonitor.swift @@ -0,0 +1,122 @@ +import Foundation + +class FolderMonitor { + private let callback: () -> 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 () -> 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 changed = false + + // Check for new or modified files + for (path, modDate) in currentFiles { + if let previousDate = knownFiles[path] { + if modDate > previousDate { + changed = true + break + } + } else { + // New file + changed = true + break + } + } + + // Check for deleted files + if !changed { + for path in knownFiles.keys { + if currentFiles[path] == nil { + changed = true + break + } + } + } + + if changed { + knownFiles = currentFiles + callback() + } + } + + 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) { + relativePath = String(path.dropFirst(basePath.count + 1)) + } else { + relativePath = 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/SagaCLI/InitCommand.swift b/Sources/SagaCLI/InitCommand.swift new file mode 100644 index 0000000..11bf4c6 --- /dev/null +++ b/Sources/SagaCLI/InitCommand.swift @@ -0,0 +1,46 @@ +import ArgumentParser +import PathKit + +struct Init: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Create a new Saga project." + ) + + @Argument(help: "The name of the project to create.") + var name: String + + func run() throws { + let projectPath = Path.current + name + + guard !projectPath.exists else { + throw ValidationError("Directory '\(name)' already exists.") + } + + let capitalizedName = name.prefix(1).uppercased() + name.dropFirst() + + // Create directory structure + try (projectPath + "Sources" + capitalizedName).mkpath() + try (projectPath + "content" + "articles").mkpath() + try (projectPath + "content" + "static").mkpath() + + // Write files + let files: [(Path, String)] = [ + (projectPath + "Package.swift", ProjectTemplate.packageSwift(name: capitalizedName)), + (projectPath + "Sources" + capitalizedName + "run.swift", ProjectTemplate.runSwift(name: capitalizedName)), + (projectPath + "Sources" + capitalizedName + "templates.swift", ProjectTemplate.templatesSwift()), + (projectPath + "content" + "index.md", ProjectTemplate.indexMarkdown()), + (projectPath + "content" + "articles" + "hello-world.md", ProjectTemplate.helloWorldMarkdown()), + (projectPath + "content" + "static" + "style.css", ProjectTemplate.styleCss()), + ] + + for (path, content) in files { + try path.write(content) + } + + print("Created new Saga project in '\(name)/'") + print("") + print("Next steps:") + print(" cd \(name)") + print(" saga dev") + } +} diff --git a/Sources/SagaCLI/ProjectTemplate.swift b/Sources/SagaCLI/ProjectTemplate.swift new file mode 100644 index 0000000..fa0da0c --- /dev/null +++ b/Sources/SagaCLI/ProjectTemplate.swift @@ -0,0 +1,353 @@ +import Foundation + +enum ProjectTemplate { + static func packageSwift(name: String) -> String { + """ + // swift-tools-version:5.5 + + import PackageDescription + + let package = Package( + name: "\(name)", + platforms: [ + .macOS(.v12), + ], + dependencies: [ + .package(url: "https://github.com/loopwerk/Saga", from: "2.0.0"), + .package(url: "https://github.com/loopwerk/SagaParsleyMarkdownReader", from: "1.0.0"), + .package(url: "https://github.com/loopwerk/SagaSwimRenderer", from: "1.0.0"), + .package(url: "https://github.com/loopwerk/Moon", from: "1.0.0"), + ], + targets: [ + .executableTarget( + name: "\(name)", + dependencies: [ + "Saga", + "SagaParsleyMarkdownReader", + "SagaSwimRenderer", + "Moon", + ] + ), + ] + ) + """ + } + + static func runSwift(name: String) -> String { + """ + import Foundation + import Saga + import SagaParsleyMarkdownReader + import SagaSwimRenderer + + struct ArticleMetadata: Metadata { + let tags: [String] + var summary: String? + } + + @main + struct Run { + static func main() async throws { + try await Saga(input: "content", output: "deploy") + .register( + folder: "articles", + metadata: ArticleMetadata.self, + readers: [.parsleyMarkdownReader], + writers: [ + .itemWriter(swim(renderArticle)), + .listWriter(swim(renderArticles)), + .tagWriter(swim(renderTag), tags: \\.metadata.tags), + ] + ) + .register( + metadata: EmptyMetadata.self, + readers: [.parsleyMarkdownReader], + writers: [.itemWriter(swim(renderPage))] + ) + .run() + .staticFiles() + } + } + """ + } + + static func templatesSwift() -> String { + #""" + import Foundation + import HTML + import Moon + import Saga + import SagaSwimRenderer + + func baseHtml(title pageTitle: String, @NodeBuilder children: () -> NodeConvertible) -> Node { + html(lang: "en-US") { + head { + meta(charset: "utf-8") + meta(content: "width=device-width, initial-scale=1", name: "viewport") + title { pageTitle } + link(href: "/static/style.css", rel: "stylesheet") + } + body { + header { + nav { + a(class: "site-title", href: "/") { "My Site" } + div(class: "nav-links") { + a(href: "/articles/") { "Articles" } + } + } + } + main { + children() + } + footer { + p { + "Built with " + a(href: "https://github.com/loopwerk/Saga") { "Saga" } + } + } + } + } + } + + func renderArticle(context: ItemRenderingContext) -> Node { + baseHtml(title: context.item.title) { + article { + h1 { context.item.title } + ul(class: "tags") { + context.item.metadata.tags.map { tag in + li { + a(href: "/articles/tag/\(tag.slugified)/") { tag } + } + } + } + Node.raw(Moon.shared.highlightCodeBlocks(in: context.item.body)) + } + } + } + + func renderArticles(context: ItemsRenderingContext) -> Node { + baseHtml(title: "Articles") { + h1 { "Articles" } + context.items.map { article in + div(class: "article-card") { + h2 { + a(href: article.url) { article.title } + } + if let summary = article.metadata.summary { + p { summary } + } + } + } + } + } + + func renderTag(context: PartitionedRenderingContext) -> Node { + baseHtml(title: "Articles tagged \(context.key)") { + h1 { "Articles tagged \(context.key)" } + context.items.map { article in + div(class: "article-card") { + h2 { + a(href: article.url) { article.title } + } + } + } + } + } + + func renderPage(context: ItemRenderingContext) -> Node { + baseHtml(title: context.item.title) { + div(class: "page") { + h1 { context.item.title } + Node.raw(Moon.shared.highlightCodeBlocks(in: context.item.body)) + } + } + } + """# + } + + static func indexMarkdown() -> String { + """ + --- + title: Home + --- + # Welcome to my site + + This site is built with [Saga](https://github.com/loopwerk/Saga), a static site generator written in Swift. + """ + } + + static func helloWorldMarkdown() -> String { + """ + --- + tags: swift, saga + summary: Getting started with Saga, a static site generator written in Swift. + date: \(currentDateString()) + --- + # Hello, World! + + This is your first article. Edit this file or create new markdown files in the `content/articles` folder. + + ## Getting Started + + Saga uses a **Reader → Processor → Writer** pipeline: + + 1. **Readers** parse your content files (like this Markdown file) into typed items + 2. **Processors** can transform items with custom logic + 3. **Writers** generate the output HTML files + + Happy writing! + """ + } + + static func styleCss() -> String { + """ + * { + margin: 0; + padding: 0; + box-sizing: border-box; + } + + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 0 20px; + } + + header nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; + border-bottom: 1px solid #eee; + margin-bottom: 40px; + } + + .site-title { + font-size: 1.2rem; + font-weight: 700; + color: #333; + text-decoration: none; + } + + .nav-links a { + color: #666; + text-decoration: none; + margin-left: 20px; + } + + .nav-links a:hover { + color: #333; + } + + main { + min-height: 60vh; + } + + h1 { + font-size: 2rem; + margin-bottom: 20px; + } + + h2 { + font-size: 1.4rem; + margin: 30px 0 10px; + } + + p { + margin-bottom: 16px; + } + + a { + color: #0066cc; + } + + a:hover { + color: #004499; + } + + article { + margin-bottom: 40px; + } + + .tags { + list-style: none; + display: flex; + gap: 8px; + margin-bottom: 20px; + } + + .tags li a { + background: #f0f0f0; + padding: 2px 10px; + border-radius: 12px; + font-size: 0.85rem; + color: #555; + text-decoration: none; + } + + .tags li a:hover { + background: #e0e0e0; + } + + .article-card { + padding: 20px 0; + border-bottom: 1px solid #eee; + } + + .article-card h2 { + margin: 0 0 8px; + } + + .article-card p { + color: #666; + margin: 0; + } + + footer { + margin-top: 60px; + padding: 20px 0; + border-top: 1px solid #eee; + color: #999; + font-size: 0.85rem; + } + + ol, ul { + margin-bottom: 16px; + padding-left: 24px; + } + + li { + margin-bottom: 4px; + } + + code { + background: #f5f5f5; + padding: 2px 6px; + border-radius: 3px; + font-size: 0.9em; + } + + pre { + background: #f5f5f5; + padding: 16px; + border-radius: 6px; + overflow-x: auto; + margin-bottom: 16px; + } + + pre code { + background: none; + padding: 0; + } + """ + } + + private static func currentDateString() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.string(from: Date()) + } +} diff --git a/Sources/SagaCLI/SagaCommand.swift b/Sources/SagaCLI/SagaCommand.swift new file mode 100644 index 0000000..bc3bed0 --- /dev/null +++ b/Sources/SagaCLI/SagaCommand.swift @@ -0,0 +1,10 @@ +import ArgumentParser + +@main +struct SagaCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "saga", + abstract: "A code-first static site generator written in Swift.", + subcommands: [Init.self, Dev.self, Build.self] + ) +} diff --git a/Sources/SagaCLI/main.swift b/Sources/SagaWatch/main.swift similarity index 99% rename from Sources/SagaCLI/main.swift rename to Sources/SagaWatch/main.swift index 0f3ed14..e2b2790 100644 --- a/Sources/SagaCLI/main.swift +++ b/Sources/SagaWatch/main.swift @@ -246,5 +246,6 @@ import PathKit } } + print("Warning: 'watch' is deprecated. Use 'saga dev' instead.") Watch.main() #endif diff --git a/Tests/SagaTests/SagaTests.swift b/Tests/SagaTests/SagaTests.swift index 0c2a607..9d6e121 100644 --- a/Tests/SagaTests/SagaTests.swift +++ b/Tests/SagaTests/SagaTests.swift @@ -214,7 +214,7 @@ final class SagaTests: XCTestCase { XCTAssertTrue(finalWrittenPages.contains(WrittenPage(destination: "root/output/test/index.html", content: "

test.md

"))) } - // If the frontmatter contains a date property then this should be set to the item's date + /// If the frontmatter contains a date property then this should be set to the item's date func testDateFromFrontMatter() async throws { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd" @@ -439,9 +439,9 @@ final class SagaTests: XCTestCase { let next = context.next?.body ?? "none" return "\(context.item.body)|prev:\(prev)|next:\(next)" }, - .listWriter({ context in + .listWriter { context in context.items.map(\.body).joined(separator: ",") - }), + }, ] ) .run() @@ -459,8 +459,8 @@ final class SagaTests: XCTestCase { XCTAssertTrue(finalWrittenPages.contains(WrittenPage(destination: "root/output/folder/sub2/c/index.html", content: "

folder/sub2/c.md

|prev:none|next:none"))) // a.md and b.md should reference each other (scoped to sub1), not c.md - let aPage = finalWrittenPages.first(where: { $0.destination == "root/output/folder/sub1/a/index.html" })! - let bPage = finalWrittenPages.first(where: { $0.destination == "root/output/folder/sub1/b/index.html" })! + let aPage = try XCTUnwrap(finalWrittenPages.first(where: { $0.destination == "root/output/folder/sub1/a/index.html" })) + let bPage = try XCTUnwrap(finalWrittenPages.first(where: { $0.destination == "root/output/folder/sub1/b/index.html" })) XCTAssertFalse(aPage.content.contains("sub2")) XCTAssertFalse(bPage.content.contains("sub2")) @@ -486,10 +486,10 @@ final class SagaTests: XCTestCase { let decoded = try TestMetadata(from: decoder) XCTAssertEqual(decoded.tags, ["one", "two"]) - XCTAssertEqual(decoded.url, URL(string: "https://www.example.com")!) + XCTAssertEqual(decoded.url, URL(string: "https://www.example.com")) } - func testSlugified() throws { + func testSlugified() { XCTAssertEqual("one two".slugified, "one-two") XCTAssertEqual("one - two".slugified, "one-two") XCTAssertEqual("One Two".slugified, "one-two")