1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248
|
/*
This source file is part of the Swift.org open source project
Copyright (c) 2021-2024 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception
See https://swift.org/LICENSE.txt for license information
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/
import Foundation
import SwiftDocC
#if canImport(NIOHTTP1)
/// A preview server instance.
var servers: [String: PreviewServer] = [:]
fileprivate func trapSignals() {
// When the user stops docc - stop the preview server first before exiting.
Signal.on(Signal.all) { _ in
// This C function wrapper can't capture context so we print to the standard output.
print("Stopping preview...")
do {
// This will unblock the execution at `server.start()`.
for server in servers.values {
try server.stop()
}
} catch {
print(error.localizedDescription)
exit(1)
}
}
}
/// An action that monitors a documentation bundle for changes and runs a live web-preview.
public final class PreviewAction: AsyncAction {
/// A test configuration allowing running multiple previews for concurrent testing.
static var allowConcurrentPreviews = false
private let printHTMLTemplatePath: Bool
let port: Int
var convertAction: ConvertAction
private var previewPaths: [String] = []
// Use for testing to override binding to a system port
var bindServerToSocketPath: String?
/// This closure is used to create a new convert action to generate a new version of the docs
/// whenever the user changes a file in the watched directory.
private let createConvertAction: () throws -> ConvertAction
/// A unique ID to access the action's preview server.
let serverIdentifier = ProcessInfo.processInfo.globallyUniqueString
/// Creates a new preview action from the given parameters.
///
/// - Parameters:
/// - port: The port number used by the preview server.
/// - createConvertAction: A closure that returns the action used to convert the documentation before preview.
///
/// On macOS, this action will be recreated each time the source is modified to rebuild the documentation.
/// - printTemplatePath: Whether or not the HTML template used by the convert action should be printed when the action
/// is performed.
/// - Throws: If an error is encountered while initializing the documentation context.
public init(
port: Int,
createConvertAction: @escaping () throws -> ConvertAction,
printTemplatePath: Bool = true
) throws {
if !Self.allowConcurrentPreviews && !servers.isEmpty {
assertionFailure("Running multiple preview actions is not allowed.")
}
// Initialize the action context.
self.port = port
self.createConvertAction = createConvertAction
self.convertAction = try createConvertAction()
self.printHTMLTemplatePath = printTemplatePath
}
/// Converts a documentation bundle and starts a preview server to render the result of that conversion.
///
/// > Important: On macOS, the bundle will be converted each time the source is modified.
///
/// - Parameter logHandle: The file handle that the convert and preview actions will print debug messages to.
public func perform(logHandle: inout LogHandle) async throws -> ActionResult {
self.logHandle.sync { $0 = logHandle }
if let rootURL = convertAction.rootURL {
print("Input: \(rootURL.path)")
}
// TODO: This never did output human readable string; rdar://74324255
// print("Input: \(convertAction.documentationCoverageOptions)", to: &self.logHandle)
// In case a developer is using a custom template log its path.
if printHTMLTemplatePath, let htmlTemplateDirectory = convertAction.htmlTemplateDirectory {
print("Template: \(htmlTemplateDirectory.path)")
}
let previewResult = try await preview()
return ActionResult(didEncounterError: previewResult.didEncounterError, outputs: [convertAction.targetDirectory])
}
/// Stops a currently running preview session.
func stop() throws {
monitoredConvertTask?.cancel()
try servers[serverIdentifier]?.stop()
servers.removeValue(forKey: serverIdentifier)
}
func preview() async throws -> ActionResult {
// Convert the documentation source for previewing.
let result = try await convert()
guard !result.didEncounterError else {
return result
}
let previewResult: ActionResult
// Preview the output and monitor the source bundle for changes.
do {
print(String(repeating: "=", count: 40))
if let previewURL = URL(string: "http://localhost:\(port)") {
print("Starting Local Preview Server")
printPreviewAddresses(base: previewURL)
print(String(repeating: "=", count: 40))
}
let to: PreviewServer.Bind = bindServerToSocketPath.map { .socket(path: $0) } ?? .localhost(port: port)
var logHandleCopy = logHandle.sync { $0 }
servers[serverIdentifier] = try PreviewServer(contentURL: convertAction.targetDirectory, bindTo: to, logHandle: &logHandleCopy)
// When the user stops docc - stop the preview server first before exiting.
trapSignals()
// Monitor the source folder if possible.
#if !os(Linux) && !os(Android)
try watch()
#endif
// This will wait until the server is manually killed.
try servers[serverIdentifier]!.start()
previewResult = ActionResult(didEncounterError: false)
} catch {
let diagnosticEngine = convertAction.diagnosticEngine
diagnosticEngine.emit(.init(description: error.localizedDescription, source: nil))
diagnosticEngine.flush()
// Stale server entry, remove it from the list
servers.removeValue(forKey: serverIdentifier)
previewResult = ActionResult(didEncounterError: true)
}
return previewResult
}
func convert() async throws -> ActionResult {
convertAction = try createConvertAction()
var logHandleCopy = logHandle.sync { $0 }
let (result, context) = try await convertAction.perform(logHandle: &logHandleCopy)
previewPaths = try context.previewPaths()
return result
}
private func printPreviewAddresses(base: URL) {
// If the preview paths are empty, just print the base.
let firstPath = previewPaths.first ?? ""
print("\t Address: \(base.appendingPathComponent(firstPath).absoluteString)")
let spacing = String(repeating: " ", count: "Address:".count)
for previewPath in previewPaths.dropFirst() {
print("\t \(spacing) \(base.appendingPathComponent(previewPath).absoluteString)")
}
}
private var logHandle: Synchronized<LogHandle> = .init(.none)
fileprivate func print(_ string: String, terminator: String = "\n") {
logHandle.sync { logHandle in
Swift.print(string, terminator: terminator, to: &logHandle)
}
}
fileprivate var monitoredConvertTask: Task<Void, Never>?
}
// Monitoring a source folder: Asynchronous output reading and file system events are supported only on macOS.
#if !os(Linux) && !os(Android)
/// If needed, a retained directory monitor.
fileprivate var monitor: DirectoryMonitor! = nil
extension PreviewAction {
private func watch() throws {
guard let rootURL = convertAction.rootURL else {
return
}
monitor = try DirectoryMonitor(root: rootURL) { _, _ in
self.print("Source bundle was modified, converting... ", terminator: "")
self.monitoredConvertTask?.cancel()
self.monitoredConvertTask = Task {
do {
let result = try await self.convert()
if result.didEncounterError {
throw ErrorsEncountered()
}
self.print("Done.")
} catch DocumentationContext.ContextError.registrationDisabled {
// The context cancelled loading the bundles and threw to yield execution early.
self.print("\nConversion cancelled...")
} catch is CancellationError {
self.print("\nConversion cancelled...")
} catch {
self.print("\n\(error.localizedDescription)\nCompilation failed")
}
}
}
try monitor.start()
self.print("Monitoring \(rootURL.path) for changes...")
}
}
#endif // !os(Linux) && !os(Android)
#endif // canImport(NIOHTTP1)
extension DocumentationContext {
/// A collection of non-implicit root modules
var renderRootModules: [ResolvedTopicReference] {
get throws {
try rootModules.filter({ try !entity(with: $0).isVirtual })
}
}
/// Finds the module and tutorial table-of-contents pages in the context and returns their paths.
func previewPaths() throws -> [String] {
let urlGenerator = PresentationURLGenerator(context: self, baseURL: URL(string: "/")!)
let rootModules = try renderRootModules
return (rootModules + tutorialTableOfContentsReferences).map { page in
urlGenerator.presentationURLForReference(page).absoluteString
}
}
}
|