import Foundation final class FileGenerator { let input: URL let outputFolder: URL let runFolder: URL private let files: FileData /// All files copied to the destination. private var copiedFiles: Set = [] /// Files which could not be read (`key`: file path relative to content) private var unreadableFiles: [String : (source: String, message: String)] = [:] /// Files which could not be written (`key`: file path relative to output folder) private var unwritableFiles: [String : (source: String, message: String)] = [:] private var failedMinifications: [(file: String, source: String, message: String)] = [] /// Non-existent files. `key`: file path, `value`: source element private var missingFiles: [String : String] = [:] private var minifiedFiles: [String] = [] private let numberOfFilesToCopy: Int private var numberOfCopiedFiles = 0 private let numberOfFilesToMinify: Int private var numberOfMinifiedFiles = 0 private var numberOfExistingFiles = 0 private var tempFile: URL { runFolder.appendingPathComponent("temp") } init(input: URL, output: URL, runFolder: URL, files: FileData) { self.input = input self.outputFolder = output self.runFolder = runFolder self.files = files numberOfFilesToCopy = files.toCopy.count numberOfFilesToMinify = files.toMinify.count } func generate() { copy(files: files.toCopy) print(" Copied files: \(copiedFiles.count)/\(numberOfFilesToCopy) ") minify(files: files.toMinify) print(" Minified files: \(minifiedFiles.count)/\(numberOfFilesToMinify) ") checkExpected(files: files.expected) print(" Expected files: \(numberOfExistingFiles)/\(files.expected.count)") print(" External files: \(files.external.count)") print("") } private func didCopyFile() { numberOfCopiedFiles += 1 print(" Copied files: \(numberOfCopiedFiles)/\(numberOfFilesToCopy) \r", terminator: "") fflush(stdout) } private func didMinifyFile() { numberOfMinifiedFiles += 1 print(" Minified files: \(numberOfMinifiedFiles)/\(numberOfFilesToMinify) \r", terminator: "") fflush(stdout) } // MARK: File copies private func copy(files: [String : String]) { for (file, source) in files { copyFileIfChanged(file: file, source: source) } } private func copyFileIfChanged(file: String, source: String) { let cleanPath = cleanRelativeURL(file) let destinationUrl = outputFolder.appendingPathComponent(cleanPath) defer { didCopyFile() } guard copyIfChanged(cleanPath, to: destinationUrl, source: source) else { return } copiedFiles.insert(cleanPath) } private func copyIfChanged(_ file: String, to destination: URL, source: String) -> Bool { let url = input.appendingPathComponent(file) do { let data = try Data(contentsOf: url) return writeIfChanged(data, file: file, source: source) } catch { markFileAsUnreadable(at: file, requiredBy: source, message: "\(error)") return false } } @discardableResult func writeIfChanged(_ data: Data, file: String, source: String) -> Bool { let url = outputFolder.appendingPathComponent(file) // Only write changed files if url.exists, let oldContent = try? Data(contentsOf: url), data == oldContent { return false } do { try data.createFolderAndWrite(to: url) return true } catch { markFileAsUnwritable(at: file, requiredBy: source, message: "Failed to write file: \(error)") return false } } // MARK: File minification private func minify(files: [String : (source: String, type: MinificationType)]) { for (file, other) in files { minify(file: file, source: other.source, type: other.type) } } private func minify(file: String, source: String, type: MinificationType) { let url = input.appendingPathComponent(file) let command: String switch type { case .js: command = "uglifyjs \(url.path) > \(tempFile.path)" case .css: command = "cleancss \(url.path) -o \(tempFile.path)" } defer { didMinifyFile() try? tempFile.delete() } let output: String do { output = try safeShell(command) } catch { failedMinifications.append((file, source, "Failed to minify with error: \(error)")) return } guard tempFile.exists else { failedMinifications.append((file, source, output)) return } let data: Data do { data = try Data(contentsOf: tempFile) } catch { markFileAsUnreadable(at: file, requiredBy: source, message: "\(error)") return } if writeIfChanged(data, file: file, source: source) { minifiedFiles.append(file) } } private func cleanRelativeURL(_ raw: String) -> String { let raw = raw.dropAfterLast("#") // Clean links to page content guard raw.contains("..") else { return raw } var result: [String] = [] for component in raw.components(separatedBy: "/") { if component == ".." { _ = result.popLast() } else { result.append(component) } } return result.joined(separator: "/") } private func markFileAsUnreadable(at path: String, requiredBy source: String, message: String) { unreadableFiles[path] = (source, message) } private func markFileAsUnwritable(at path: String, requiredBy source: String, message: String) { unwritableFiles[path] = (source, message) } // MARK: File expectationts private func checkExpected(files: [String: String]) { for (file, source) in files { guard !isExternal(file: file) else { numberOfExistingFiles += 1 continue } let cleanPath = cleanRelativeURL(file) let destinationUrl = outputFolder.appendingPathComponent(cleanPath) guard destinationUrl.exists else { markFileAsMissing(at: cleanPath, requiredBy: source) continue } numberOfExistingFiles += 1 } } private func markFileAsMissing(at path: String, requiredBy source: String) { missingFiles[path] = source } /** Check if a file is marked as external. Also checks for sub-paths of the file, e.g if the folder `docs` is marked as external, then files like `docs/index.html` are also found to be external. - Note: All paths are either relative to root (no leading slash) or absolute paths of the domain (leading slash) */ private func isExternal(file: String) -> Bool { // Deconstruct file path var path = "" for part in file.components(separatedBy: "/") { guard part != "" else { continue } if path == "" { path = part } else { path += "/" + part } if files.external[path] != nil { return true } } return false } func writeResults(to file: URL) { guard !unreadableFiles.isEmpty || !unwritableFiles.isEmpty || !failedMinifications.isEmpty || !missingFiles.isEmpty || !copiedFiles.isEmpty || !minifiedFiles.isEmpty || !files.expected.isEmpty || !files.external.isEmpty else { do { try FileManager.default.removeItem(at: file) } catch { print(" Failed to delete file log: \(error)") } return } var lines: [String] = [] func add(_ name: String, _ property: S, convert: (S.Element) -> String) where S: Sequence { let elements = property.map { " " + convert($0) }.sorted() guard !elements.isEmpty else { return } lines.append("\(name):") lines.append(contentsOf: elements) } add("Unreadable files", unreadableFiles) { "\($0.key) (required by \($0.value.source)): \($0.value.message)" } add("Unwritable files", unwritableFiles) { "\($0.key) (required by \($0.value.source)): \($0.value.message)" } add("Failed minifications", failedMinifications) { "\($0.file) (required by \($0.source)): \($0.message)" } add("Missing files", missingFiles) { "\($0.key) (required by \($0.value))" } add("Copied files", copiedFiles) { $0 } add("Minified files", minifiedFiles) { $0 } add("Expected files", files.expected) { "\($0.key) (required by \($0.value))" } add("External files", files.external) { "\($0.key) (required by \($0.value))" } let data = lines.joined(separator: "\n").data(using: .utf8)! do { try data.createFolderAndWrite(to: file) } catch { print(" Failed to save file log: \(error)") } } }