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: "