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
|
/*
This source file is part of the Swift.org open source project
Copyright (c) 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
/// An action that merges a list of documentation archives into a combined archive.
struct MergeAction: Action {
var archives: [URL]
var landingPageCatalog: URL?
var outputURL: URL
var fileManager: FileManagerProtocol
mutating func perform(logHandle: LogHandle) throws -> ActionResult {
guard let firstArchive = archives.first else {
// A validation warning should have already been raised in `Docc/Merge/InputAndOutputOptions/validate()`.
return ActionResult(didEncounterError: true, outputs: [])
}
try validateThatOutputIsEmpty()
try validateThatArchivesHaveDisjointData()
let supportsStaticHosting = try validateThatAllArchivesOrNoArchivesSupportStaticHosting()
let targetURL = try Self.createUniqueDirectory(inside: fileManager.uniqueTemporaryDirectory(), template: firstArchive, fileManager: fileManager)
defer {
try? fileManager.removeItem(at: targetURL)
}
// TODO: Merge the LMDB navigator index
let jsonIndexURL = targetURL.appendingPathComponent("index/index.json")
guard let jsonIndexData = fileManager.contents(atPath: jsonIndexURL.path) else {
throw CocoaError.error(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: jsonIndexURL.path])
}
var combinedJSONIndex = try JSONDecoder().decode(RenderIndex.self, from: jsonIndexData)
// Ensure that the destination has a data directory in case the first archive didn't have any pages.
try? fileManager.createDirectory(at: targetURL.appendingPathComponent("data", isDirectory: true), withIntermediateDirectories: false, attributes: nil)
let directoriesToCopy = ["data/documentation", "data/tutorials", "images", "videos", "downloads"] + (supportsStaticHosting ? ["documentation", "tutorials"] : [])
for archive in archives.dropFirst() {
for directoryToCopy in directoriesToCopy {
let fromDirectory = archive.appendingPathComponent(directoryToCopy, isDirectory: true)
let toDirectory = targetURL.appendingPathComponent(directoryToCopy, isDirectory: true)
// Ensure that the destination directory exist in case the first archive didn't have that kind of pages.
// This is necessary when merging a reference-only archive with a tutorial-only archive.
try? fileManager.createDirectory(at: toDirectory, withIntermediateDirectories: false, attributes: nil)
for from in (try? fileManager.contentsOfDirectory(at: fromDirectory, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] {
// Copy each file or subdirectory
try fileManager.copyItem(at: from, to: toDirectory.appendingPathComponent(from.lastPathComponent))
}
}
guard let jsonIndexData = fileManager.contents(atPath: archive.appendingPathComponent("index/index.json").path) else {
throw CocoaError.error(.fileReadNoSuchFile, userInfo: [NSFilePathErrorKey: archive.appendingPathComponent("index/index.json").path])
}
let renderIndex = try JSONDecoder().decode(RenderIndex.self, from: jsonIndexData)
try combinedJSONIndex.merge(renderIndex)
}
try fileManager.createFile(at: jsonIndexURL, contents: RenderJSONEncoder.makeEncoder(emitVariantOverrides: false).encode(combinedJSONIndex))
// TODO: Build landing page from input or synthesize default landing page
// TODO: Inactivate external links outside the merged archives
try Self.moveOutput(from: targetURL, to: outputURL, fileManager: fileManager)
return ActionResult(didEncounterError: false, outputs: [outputURL])
}
/// Validate that the different archives don't have overlapping data.
private func validateThatArchivesHaveDisjointData() throws {
// Check that the archives don't have overlapping data
typealias ArchivesByDirectoryName = [String: [String: Set<String>]]
var archivesByTopLevelDirectory = ArchivesByDirectoryName()
// Gather all the top level /data/documentation and /data/tutorials directories to ensure that the different archives don't have overlapping data
for archive in archives {
for typeOfDocumentation in (try? fileManager.contentsOfDirectory(at: archive.appendingPathComponent("data", isDirectory: true), includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] {
for moduleOrTechnologyName in (try? fileManager.contentsOfDirectory(at: typeOfDocumentation, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? [] {
archivesByTopLevelDirectory[typeOfDocumentation.lastPathComponent, default: [:]][moduleOrTechnologyName.deletingPathExtension().lastPathComponent, default: []].insert(archive.lastPathComponent)
}
}
}
// Only data directories found in a multiple archives is a problem
archivesByTopLevelDirectory = archivesByTopLevelDirectory.mapValues { collected in
collected.filter { $0.value.count > 1 }
}
guard archivesByTopLevelDirectory.allSatisfy({ $0.value.isEmpty }) else {
struct OverlappingDataError: DescribedError {
var archivesByTopLevelDirectory: ArchivesByDirectoryName
var errorDescription: String {
var message = "Input archives contain overlapping data"
for (typeOfDocumentation, archivesByData) in archivesByTopLevelDirectory.sorted(by: { $0.key < $1.key }) {
if let overlappingDocumentationDescription = overlapDescription(archivesByData: archivesByData, pathComponentName: typeOfDocumentation) {
message.append(overlappingDocumentationDescription)
}
}
return message
}
private func overlapDescription(archivesByData: ArchivesByDirectoryName.Value, pathComponentName: String) -> String? {
guard !archivesByData.isEmpty else {
return nil
}
var description = "\n"
for (topLevelDirectory, archives) in archivesByData.mapValues({ $0.sorted() }) {
if archives.count == 2 {
description.append("\n'\(archives.first!)' and '\(archives.last!)' both ")
} else {
description.append("\n\(archives.dropLast().map({ "'\($0)'" }).joined(separator: ", ")), and '\(archives.last!)' all ")
}
description.append("contain '/data/\(pathComponentName)/\(topLevelDirectory)/'")
}
return description
}
}
throw OverlappingDataError(archivesByTopLevelDirectory: archivesByTopLevelDirectory)
}
}
/// Validate that the output directory is empty.
private func validateThatOutputIsEmpty() throws {
guard fileManager.directoryExists(atPath: outputURL.path) else {
return
}
let existingContents = (try? fileManager.contentsOfDirectory(at: outputURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles)) ?? []
guard existingContents.isEmpty else {
struct NonEmptyOutputError: DescribedError {
var existingContents: [URL]
var fileManager: FileManagerProtocol
var errorDescription: String {
var contentDescriptions = existingContents
.sorted(by: { $0.lastPathComponent < $1.lastPathComponent })
.prefix(6)
.map { " - \($0.lastPathComponent)\(fileManager.directoryExists(atPath: $0.path) ? "/" : "")" }
if existingContents.count > 6 {
contentDescriptions[5] = "and \(existingContents.count - 5) more files and directories"
}
return """
Output directory is not empty. It contains:
\(contentDescriptions.joined(separator: "\n"))
"""
}
}
throw NonEmptyOutputError(existingContents: existingContents, fileManager: fileManager)
}
}
/// Validate that either all archives support static hosting or that no archives support static hosting.
/// - Returns: `true` if all archives support static hosting; `false` otherwise.
private func validateThatAllArchivesOrNoArchivesSupportStaticHosting() throws -> Bool {
let nonEmptyArchives = archives.filter {
fileManager.directoryExists(atPath: $0.appendingPathComponent("data").path)
}
let archivesWithStaticHostingSupport = nonEmptyArchives.filter {
return fileManager.directoryExists(atPath: $0.appendingPathComponent("documentation").path)
|| fileManager.directoryExists(atPath: $0.appendingPathComponent("tutorials").path)
}
guard archivesWithStaticHostingSupport.count == nonEmptyArchives.count // All archives support static hosting
|| archivesWithStaticHostingSupport.count == 0 // No archives support static hosting
else {
struct DifferentStaticHostingSupportError: DescribedError {
var withSupport: Set<String>
var withoutSupport: Set<String>
var errorDescription: String {
"""
Different static hosting support in different archives.
\(withSupport.sorted().joined(separator: ", ")) support\(withSupport.count == 1 ? "s" : "") static hosting \
but \(withoutSupport.sorted().joined(separator: ", ")) do\(withoutSupport.count == 1 ? "es" : "")n't.
"""
}
}
let allArchiveNames = Set(nonEmptyArchives.map(\.lastPathComponent))
let archiveNamesWithStaticHostingSupport = Set(archivesWithStaticHostingSupport.map(\.lastPathComponent))
throw DifferentStaticHostingSupportError(
withSupport: archiveNamesWithStaticHostingSupport,
withoutSupport: allArchiveNames.subtracting(archiveNamesWithStaticHostingSupport)
)
}
return !archivesWithStaticHostingSupport.isEmpty
}
}
|