2022-12-04 19:15:22 +01:00
|
|
|
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<String> = []
|
|
|
|
|
|
|
|
/// 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("")
|
|
|
|
}
|
|
|
|
|
|
|
|
func writeResultsToFile(file: URL) throws {
|
|
|
|
var lines: [String] = []
|
|
|
|
func add<S>(_ 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("Missing files", missingFiles) { "\($0.key) (required by \($0.value))" }
|
|
|
|
add("Unreadable files", unreadableFiles) { "\($0.key) (required by \($0.value.source)): \($0.value.message)" }
|
|
|
|
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("External files", files.external) { "\($0.key) (from \($0.value))" }
|
|
|
|
|
|
|
|
let data = lines.joined(separator: "\n").data(using: .utf8)!
|
|
|
|
try data.createFolderAndWrite(to: file)
|
|
|
|
}
|
|
|
|
|
|
|
|
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)"
|
|
|
|
}
|
|
|
|
|
2022-12-04 23:10:44 +01:00
|
|
|
defer {
|
|
|
|
didMinifyFile()
|
|
|
|
try? tempFile.delete()
|
|
|
|
}
|
2022-12-04 19:15:22 +01:00
|
|
|
|
|
|
|
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
|
|
|
|
}
|
2022-12-04 23:10:44 +01:00
|
|
|
|
|
|
|
func writeResults(to file: URL) {
|
|
|
|
var lines: [String] = []
|
|
|
|
func add<S>(_ 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))" }
|
|
|
|
|
|
|
|
let data = lines.joined(separator: "\n").data(using: .utf8)!
|
|
|
|
do {
|
|
|
|
try data.createFolderAndWrite(to: file)
|
|
|
|
} catch {
|
|
|
|
print(" Failed to save log: \(error)")
|
|
|
|
}
|
|
|
|
}
|
2022-12-04 19:15:22 +01:00
|
|
|
}
|