Compare commits

...

10 Commits

Author SHA1 Message Date
Christoph Hagen
6e24c27fdc Determine elements for news section 2022-12-01 15:39:39 +01:00
Christoph Hagen
92d832dc44 Remove global site root 2022-12-01 15:25:55 +01:00
Christoph Hagen
90d2573d0c Add imageOptim to install script 2022-12-01 15:19:41 +01:00
Christoph Hagen
94375f3a81 Remove global configuration and improve printing 2022-12-01 15:19:17 +01:00
Christoph Hagen
58eae51d40 Shorten metadata logging 2022-12-01 15:03:29 +01:00
Christoph Hagen
27b8d5b3ee Add warnings and errors to output 2022-12-01 14:52:36 +01:00
Christoph Hagen
1ceba25d4f Improve logging during element scanning 2022-12-01 14:50:26 +01:00
Christoph Hagen
4c2c4b7dd3 Optimize images with ImageOptim 2022-11-30 15:29:51 +01:00
Christoph Hagen
58f7642ca5 Fix links and images in related content 2022-11-27 22:06:18 +01:00
Christoph Hagen
112bbe252c Generate avif and webp image versions 2022-11-27 20:31:56 +01:00
32 changed files with 738 additions and 312 deletions

View File

@ -0,0 +1,6 @@
import Foundation
protocol DefaultValueProvider {
static var defaultValue: Self { get }
}

View File

@ -57,7 +57,7 @@ extension Element {
- Note: If this value is inherited from the parent, if it is not defined. There must be at least one
element in the path that defines this property.
*/
let moreLinkText: String
let moreLinkText: String?
/**
The text on the back navigation link of **contained** elements.
@ -145,77 +145,52 @@ extension Element {
extension Element.LocalizedMetadata {
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata) {
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata, log: MetadataInfoLogger) {
// Go through all elements and check them for completeness
// In the end, check that all required elements are present
var isComplete = true
func markAsIncomplete() {
isComplete = false
}
var isValid = true
let source = "root"
self.language = log
.required(data.language, name: "language", source: source)
.ifNil(markAsIncomplete) ?? ""
self.title = log
.required(data.title, name: "title", source: source)
.ifNil(markAsIncomplete) ?? ""
self.language = log.required(data.language, name: "language", source: source, &isValid)
self.title = log.required(data.title, name: "title", source: source, &isValid)
self.subtitle = data.subtitle
self.description = data.description
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
self.linkPreviewImage = log
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
self.linkPreviewImage = data.linkPreviewImage
let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle
self.linkPreviewDescription = log
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
self.backLinkText = log
.required(data.backLinkText, name: "backLinkText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.linkPreviewDescription = log.required(linkPreviewDescription, name: "linkPreviewDescription", source: source, &isValid)
self.moreLinkText = data.moreLinkText
self.backLinkText = log.required(data.backLinkText, name: "backLinkText", source: source, &isValid)
self.parentBackLinkText = "" // Root has no parent
self.placeholderTitle = log
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
.ifNil(markAsIncomplete) ?? ""
self.placeholderText = log
.required(data.placeholderText, name: "placeholderText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.placeholderTitle = log.required(data.placeholderTitle, name: "placeholderTitle", source: source, &isValid)
self.placeholderText = log.required(data.placeholderText, name: "placeholderText", source: source, &isValid)
self.titleSuffix = data.titleSuffix
self.thumbnailSuffix = log.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
self.cornerText = log.unused(data.cornerText, "cornerText", source: source)
self.externalUrl = log.unexpected(data.externalUrl, name: "externalUrl", source: source)
self.relatedContentText = log
.required(data.relatedContentText, name: "relatedContentText", source: source) ?? ""
self.navigationTextAsNextPage = log
.required(data.navigationTextAsNextPage, name: "navigationTextAsNextPage", source: source) ?? ""
self.navigationTextAsPreviousPage = log
.required(data.navigationTextAsPreviousPage, name: "navigationTextAsPreviousPage", source: source) ?? ""
self.externalUrl = log.unused(data.externalUrl, "externalUrl", source: source)
self.relatedContentText = log.required(data.relatedContentText, name: "relatedContentText", source: source, &isValid)
self.navigationTextAsNextPage = log.required(data.navigationTextAsNextPage, name: "navigationTextAsNextPage", source: source, &isValid)
self.navigationTextAsPreviousPage = log.required(data.navigationTextAsPreviousPage, name: "navigationTextAsPreviousPage", source: source, &isValid)
guard isComplete else {
guard isValid else {
return nil
}
}
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata) {
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata, log: MetadataInfoLogger) {
// Go through all elements and check them for completeness
// In the end, check that all required elements are present
var isComplete = true
func markAsIncomplete() {
isComplete = false
}
var isValid = true
self.language = parent.language
self.title = log
.required(data.title, name: "title", source: source)
.ifNil(markAsIncomplete) ?? ""
self.title = log.required(data.title, name: "title", source: source, &isValid)
self.subtitle = data.subtitle
self.description = data.description
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
self.linkPreviewImage = log
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
self.linkPreviewImage = data.linkPreviewImage
let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle
self.linkPreviewDescription = log
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = log.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
self.linkPreviewDescription = log.required(linkPreviewDescription, name: "linkPreviewDescription", source: source, &isValid)
self.moreLinkText = log.required(data.moreLinkText ?? parent.moreLinkText, name: "moreLinkText", source: source, &isValid)
self.backLinkText = data.backLinkText ?? data.title ?? ""
self.parentBackLinkText = parent.backLinkText
self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle
@ -228,7 +203,7 @@ extension Element.LocalizedMetadata {
self.navigationTextAsPreviousPage = data.navigationTextAsPreviousPage ?? parent.navigationTextAsPreviousPage
self.navigationTextAsNextPage = data.navigationTextAsNextPage ?? parent.navigationTextAsNextPage
guard isComplete else {
guard isValid else {
return nil
}
}

View File

@ -126,6 +126,12 @@ struct Element {
*/
let headerType: HeaderType
/**
Indicate that the overview section should contain a `Newest Content` section before the other sections.
- Note: If not specified, this property defaults to `false`
*/
let showMostRecentSection: Bool
/**
The localized metadata for each language.
*/
@ -157,122 +163,130 @@ struct Element {
- Parameter folder: The root folder of the site content.
- Note: Uses global objects.
*/
init?(atRoot folder: URL) {
init?(atRoot folder: URL, log: MetadataInfoLogger) {
self.inputFolder = folder
self.path = ""
let source = GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else {
guard let metadata = GenericMetadata(source: source, log: log) else {
return nil
}
var isValid = true
self.id = metadata.customId ?? Element.defaultRootId
self.author = log.required(metadata.author, name: "author", source: source) ?? "author"
self.topBarTitle = log
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
self.date = log.unused(metadata.date, "date", source: source)
self.endDate = log.unused(metadata.endDate, "endDate", source: source)
self.state = log.state(metadata.state, source: source)
self.author = log.required(metadata.author, name: "author", source: source, &isValid)
self.topBarTitle = log.required(metadata.topBarTitle, name: "topBarTitle", source: source, &isValid)
self.date = log.castUnused(metadata.date, "date", source: source)
self.endDate = log.castUnused(metadata.endDate, "endDate", source: source)
self.state = log.cast(metadata.state, "state", source: source)
self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source)
self.externalFiles = metadata.externalFiles ?? []
self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: "") } ?? []
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
self.thumbnailStyle = log.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true
self.thumbnailStyle = log.castUnused(metadata.thumbnailStyle, "thumbnailStyle", source: source)
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source)
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
self.headerType = log.headerType(metadata.headerType, source: source)
self.languages = log.required(metadata.languages, name: "languages", source: source)?
self.headerType = log.cast(metadata.headerType, "headerType", source: source)
self.showMostRecentSection = metadata.showMostRecentSection ?? false
self.languages = log.required(metadata.languages, name: "languages", source: source, &isValid)
.compactMap { language in
.init(atRoot: folder, data: language)
} ?? []
.init(atRoot: folder, data: language, log: log)
}
// All properties initialized
guard !languages.isEmpty else {
log.add(error: "No languages found", source: source)
log.error("No languages found", source: source)
return nil
}
guard isValid else {
return nil
}
files.add(page: path, id: id)
self.readElements(in: folder, source: nil)
self.readElements(in: folder, source: nil, log: log)
}
mutating func readElements(in folder: URL, source: String?) {
mutating func readElements(in folder: URL, source: String?, log: MetadataInfoLogger) {
let subFolders: [URL]
do {
subFolders = try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.isDirectory }
} catch {
log.add(error: "Failed to read subfolders", source: source ?? "root", error: error)
log.error("Failed to read subfolders: \(error)", source: source ?? "root")
return
}
self.elements = subFolders.compactMap { subFolder in
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent
return Element(parent: self, folder: subFolder, path: s)
return Element(parent: self, folder: subFolder, path: s, log: log)
}
}
init?(parent: Element, folder: URL, path: String) {
init?(parent: Element, folder: URL, path: String, log: MetadataInfoLogger) {
self.inputFolder = folder
self.path = path
let source = path + "/" + GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else {
guard let metadata = GenericMetadata(source: source, log: log) else {
return nil
}
var isValid = true
self.id = metadata.customId ?? folder.lastPathComponent
self.author = metadata.author ?? parent.author
self.topBarTitle = log
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
let date = log.date(from: metadata.date, property: "date", source: source).ifNil {
if !parent.useManualSorting {
log.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source)
}
}
self.date = date
self.endDate = log.date(from: metadata.endDate, property: "endDate", source: source).ifNotNil {
if date == nil {
log.add(warning: "Set 'endDate', but no 'date'", source: source)
}
}
let state = log.state(metadata.state, source: source)
self.state = state
self.sortIndex = metadata.sortIndex.ifNil {
if state != .hidden, parent.useManualSorting {
log.add(error: "No 'sortIndex', but parent defines 'useManualSorting' = true", source: source)
}
}
self.topBarTitle = log.unused(metadata.topBarTitle, "topBarTitle", source: source)
self.date = metadata.date.unwrapped { log.cast($0, "date", source: source) }
self.endDate = metadata.date.unwrapped { log.cast($0, "endDate", source: source) }
self.state = log.cast(metadata.state, "state", source: source)
self.sortIndex = metadata.sortIndex
// TODO: Propagate external files from the parent if subpath matches?
self.externalFiles = Element.rootPaths(for: metadata.externalFiles, path: path)
self.requiredFiles = Element.rootPaths(for: metadata.requiredFiles, path: path)
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: path) } ?? []
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
self.thumbnailStyle = log.thumbnailStyle(metadata.thumbnailStyle, source: source)
self.thumbnailStyle = log.cast(metadata.thumbnailStyle, "thumbnailStyle", source: source)
self.useManualSorting = metadata.useManualSorting ?? false
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
self.headerType = log.headerType(metadata.headerType, source: source)
self.headerType = log.cast(metadata.headerType, "headerType", source: source)
self.showMostRecentSection = metadata.showMostRecentSection ?? false
self.languages = parent.languages.compactMap { parentData in
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
log.add(info: "Language '\(parentData.language)' not found", source: source)
log.warning("Language '\(parentData.language)' not found", source: source)
return nil
}
return .init(folder: folder, data: data, source: source, parent: parentData)
return .init(folder: folder, data: data, source: source, parent: parentData, log: log)
}
// Check that each 'language' tag is present, and that all languages appear in the parent
log.required(metadata.languages, name: "languages", source: source)?
.compactMap { log.required($0.language, name: "language", source: source) }
log.required(metadata.languages, name: "languages", source: source, &isValid)
.compactMap { log.required($0.language, name: "language", source: source, &isValid) }
.filter { language in
!parent.languages.contains { $0.language == language }
}
.forEach {
log.add(warning: "Language '\($0)' not found in parent, so not generated", source: source)
log.warning("Language '\($0)' not found in parent, so not generated", source: source)
}
// All properties initialized
if self.date == nil, !parent.useManualSorting {
log.error("No 'date', but parent defines 'useManualSorting' = false", source: source)
}
if date == nil {
log.unused(self.endDate, "endDate", source: source)
}
if self.sortIndex == nil, state != .hidden, parent.useManualSorting {
log.error("No 'sortIndex', but parent defines 'useManualSorting' = true", source: source)
}
guard isValid else {
return nil
}
files.add(page: path, id: id)
self.readElements(in: folder, source: path)
self.readElements(in: folder, source: path, log: log)
}
}
@ -310,6 +324,17 @@ extension Element {
}
}
func mostRecentElements(_ count: Int) -> [Element] {
guard self.containsElements else {
return [self]
}
let all = shownItems
.reduce(into: [Element]()) { $0 += $1.mostRecentElements(count) }
.filter { $0.date != nil }
.sorted { $0.date! > $1.date! }
return Array(all.prefix(count))
}
var sortedItems: [Element] {
if useManualSorting {
return shownItems.sorted { $0.sortIndex! < $1.sortIndex! }
@ -357,7 +382,7 @@ extension Element {
// The relative path needs to go down to the first common folder,
// before going up to the target page
let allParts = [String](repeating: "..", count: ownParts.count-index)
+ pageParts.dropFirst(index)
+ pageParts.dropFirst(index)
return allParts.joined(separator: "/")
}

View File

@ -14,8 +14,8 @@ extension GenericMetadata {
let language: String?
/**
- Note: This field is mandatory
The title used in the page header.
- Note: This field is mandatory
*/
let title: String?

View File

@ -125,6 +125,12 @@ struct GenericMetadata {
*/
let headerType: String?
/**
Indicate that the overview section should contain a `Newest Content` section before the other sections.
- Note: If not specified, this property defaults to `false`
*/
let showMostRecentSection: Bool?
/**
The localized metadata for each language.
*/
@ -150,6 +156,7 @@ extension GenericMetadata: Codable {
.useManualSorting,
.overviewItemCount,
.headerType,
.showMostRecentSection,
.languages,
]
}
@ -169,8 +176,8 @@ extension GenericMetadata {
- Note: The decoding routine also checks for unknown properties, and writes them to the output.
- Note: Uses global objects
*/
init?(source: String) {
guard let data = files.dataOfOptionalFile(atPath: source, source: source) else {
init?(source: String, log: MetadataInfoLogger) {
guard let data = log.readPotentialMetadata(atPath: source, source: source) else {
return nil
}
@ -196,8 +203,7 @@ extension GenericMetadata {
do {
self = try decoder.decode(from: data)
} catch {
print("Here \(data)")
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
log.failedToDecodeMetadata(source: source, error: error)
return nil
}
}
@ -222,6 +228,7 @@ extension GenericMetadata {
useManualSorting: false,
overviewItemCount: 6,
headerType: "left",
showMostRecentSection: false,
languages: [.full])
}
}

View File

@ -17,3 +17,19 @@ enum HeaderType: String {
*/
case none
}
extension HeaderType: StringProperty {
init?(_ value: String) {
self.init(rawValue: value)
}
static var castFailureReason: String {
"Header type must be 'left', 'center' or 'none'"
}
}
extension HeaderType: DefaultValueProvider {
static var defaultValue: HeaderType { .left }
}

View File

@ -39,3 +39,19 @@ extension PageState {
}
}
}
extension PageState: StringProperty {
init?(_ value: String) {
self.init(rawValue: value)
}
static var castFailureReason: String {
"Page state must be 'standard', 'draft' or 'hidden'"
}
}
extension PageState: DefaultValueProvider {
static var defaultValue: PageState { .standard }
}

View File

@ -0,0 +1,8 @@
import Foundation
protocol StringProperty {
init?(_ value: String)
static var castFailureReason: String { get }
}

View File

@ -33,3 +33,19 @@ enum ThumbnailStyle: String, CaseIterable {
extension ThumbnailStyle: Codable {
}
extension ThumbnailStyle: StringProperty {
init?(_ value: String) {
self.init(rawValue: value)
}
static var castFailureReason: String {
"Thumbnail style must be 'large', 'square' or 'small'"
}
}
extension ThumbnailStyle: DefaultValueProvider {
static var defaultValue: ThumbnailStyle { .large }
}

View File

@ -0,0 +1,6 @@
import Foundation
extension Array: DefaultValueProvider {
static var defaultValue: Array<Element> { [] }
}

View File

@ -0,0 +1,6 @@
import Foundation
extension Bool: DefaultValueProvider {
static var defaultValue: Bool { true }
}

View File

@ -0,0 +1,26 @@
import Foundation
extension Date: StringProperty {
private static let metadataDate: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yy"
return df
}()
init?(_ value: String) {
guard let date = Date.metadataDate.date(from: value) else {
return nil
}
self = date
}
static var castFailureReason: String {
"Date string format must be 'dd.MM.yy'"
}
}
extension Date: DefaultValueProvider {
static var defaultValue: Date { .init() }
}

View File

@ -0,0 +1,13 @@
import Foundation
extension Int: StringProperty {
static var castFailureReason: String {
"The string was not a valid integer"
}
}
extension Int: DefaultValueProvider {
static var defaultValue: Int { 0 }
}

View File

@ -3,7 +3,7 @@ import Metal
extension Optional {
func unwrapped<T>(_ closure: (Wrapped) -> T) -> T? {
func unwrapped<T>(_ closure: (Wrapped) -> T?) -> T? {
if case let .some(value) = self {
return closure(value)
}

View File

@ -20,6 +20,11 @@ extension String {
.joined(separator: "\n")
}
/**
Remove the part after the last occurence of the separator (including the separator itself).
The string is left unchanges, if it does not contain the separator.
*/
func dropAfterLast(_ separator: String) -> String {
guard contains(separator) else {
return self
@ -74,3 +79,8 @@ extension String {
try data(using: .utf8)!.createFolderAndWrite(to: url)
}
}
extension String: DefaultValueProvider {
static var defaultValue: String { "" }
}

View File

@ -56,4 +56,13 @@ struct Configuration: Codable {
var outputDirectory: URL {
.init(fileURLWithPath: outputPath)
}
func printOverview() {
print(" Source folder: \(contentDirectory.path)")
print(" Output folder: \(outputDirectory.path)")
print(" Page width: \(pageImageWidth)")
print(" Minify JavaScript: \(minifyCSSandJS)")
print(" Minify CSS: \(minifyCSSandJS)")
print(" Create markdown files: \(createMdFilesIfMissing)")
}
}

View File

@ -14,10 +14,14 @@ final class FileSystem {
private let images: ImageGenerator
private let configuration: Configuration
private var tempFile: URL {
input.appendingPathComponent(FileSystem.tempFileName)
}
let generatorInfoFolder: URL
/**
All files which should be copied to the output folder
*/
@ -66,11 +70,12 @@ final class FileSystem {
*/
private var generatedPages: Set<String> = []
init(in input: URL, to output: URL) {
init(in input: URL, to output: URL, configuration: Configuration) {
self.input = input
self.output = output
self.images = .init(input: input, output: output)
self.generatorInfoFolder = input.appendingPathComponent("run")
self.configuration = configuration
}
func urlInOutputFolder(_ path: String) -> URL {
@ -100,18 +105,8 @@ final class FileSystem {
}
}
func dataOfOptionalFile(atPath path: String, source: String) -> Data? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
return nil
}
do {
return try Data(contentsOf: url)
} catch {
log.failedToOpen(path, requiredBy: source, error: error)
return nil
}
func contentOfMdFile(atPath path: String, source: String) -> String? {
contentOfOptionalFile(atPath: path, source: source, createEmptyFileIfMissing: configuration.createMdFilesIfMissing)
}
func contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
@ -138,8 +133,30 @@ final class FileSystem {
// MARK: Images
@discardableResult
func requireImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
images.requireImage(at: destination, generatedFrom: source, requiredBy: path, width: width, height: desiredHeight)
func requireSingleImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
images.requireImage(
at: destination,
generatedFrom: source,
requiredBy: path,
quality: 0.7,
width: width,
height: desiredHeight,
alwaysGenerate: false)
}
/**
Create images of different types.
This function generates versions for the given image, including png/jpg, avif, and webp. Different pixel density versions (1x and 2x) are also generated.
- Parameter destination: The path to the destination file
*/
@discardableResult
func requireMultiVersionImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
images.requireMultiVersionImage(source: source, destination: destination, requiredBy: path, width: width, desiredHeight: desiredHeight)
}
func requireFullSizeMultiVersionImage(source: String, destination: String, requiredBy path: String) -> NSSize {
images.requireMultiVersionImage(source: source, destination: destination, requiredBy: path, width: configuration.pageImageWidth, desiredHeight: nil)
}
func createImages() {
@ -252,7 +269,7 @@ final class FileSystem {
private func minifyJS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool {
let command = "uglifyjs \(sourceUrl.path) > \(tempFile.path)"
do {
_ = try safeShell(command)
_ = try FileSystem.safeShell(command)
return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl)
} catch {
log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source)
@ -263,7 +280,7 @@ final class FileSystem {
private func minifyCSS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool {
let command = "cleancss \(sourceUrl.path) -o \(tempFile.path)"
do {
_ = try safeShell(command)
_ = try FileSystem.safeShell(command)
return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl)
} catch {
log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source)
@ -406,7 +423,7 @@ final class FileSystem {
// MARK: Running other tasks
@discardableResult
func safeShell(_ command: String) throws -> String {
static func safeShell(_ command: String) throws -> String {
let task = Process()
let pipe = Pipe()

View File

@ -1,6 +1,7 @@
import Foundation
import AppKit
import CryptoKit
import Darwin.C
private struct ImageJob {
@ -9,10 +10,18 @@ private struct ImageJob {
let width: Int
let path: String
let quality: Float
let alwaysGenerate: Bool
}
final class ImageGenerator {
private let imageOptimSupportedFileExtensions: Set<String> = ["jpg", "png", "svg"]
private let imageOptimizationBatchSize = 50
/**
The path to the input folder.
*/
@ -30,6 +39,13 @@ final class ImageGenerator {
*/
private var imageJobs: [String : [ImageJob]] = [:]
/**
The images for which to generate multiple versions
The key is the source file, the value is the path of the requiring page.
*/
private var multiImageJobs: [String : String] = [:]
/**
The images which could not be found, but are required for the site.
@ -54,6 +70,11 @@ final class ImageGenerator {
*/
private var generatedImages: Set<String> = []
/**
The images optimized by ImageOptim
*/
private var optimizedImages: Set<String> = []
/**
A cache to get the size of source images, so that files don't have to be loaded multiple times.
@ -112,7 +133,7 @@ final class ImageGenerator {
}
}
func requireImage(at destination: String, generatedFrom source: String, requiredBy path: String, width: Int, height: Int?) -> NSSize {
func requireImage(at destination: String, generatedFrom source: String, requiredBy path: String, quality: Float, width: Int, height: Int?, alwaysGenerate: Bool) -> NSSize {
requiredImages.insert(destination)
let height = height.unwrapped(CGFloat.init)
let sourceUrl = input.appendingPathComponent(source)
@ -135,28 +156,44 @@ final class ImageGenerator {
}
}
let job = ImageJob(destination: destination, width: width, path: path)
let job = ImageJob(
destination: destination,
width: width,
path: path,
quality: quality,
alwaysGenerate: alwaysGenerate)
insert(job: job, source: source)
guard let existingSource = imageJobs[source] else {
imageJobs[source] = [job]
return scaledSize
}
guard let existingJob = existingSource.first(where: { $0.destination == destination}) else {
imageJobs[source] = existingSource + [job]
return scaledSize
}
if existingJob.width != width {
addWarning("Multiple image widths (\(existingJob.width) and \(width))", destination: destination, path: "\(existingJob.path) and \(path)")
}
return scaledSize
}
func createImages() {
for (source, jobs) in imageJobs.sorted(by: { $0.key < $1.key }) {
create(images: jobs, from: source)
private func insert(job: ImageJob, source: String) {
guard let existingSource = imageJobs[source] else {
imageJobs[source] = [job]
return
}
guard let existingJob = existingSource.first(where: { $0.destination == job.destination }) else {
imageJobs[source] = existingSource + [job]
return
}
if existingJob.width != job.width {
addWarning("Multiple image widths (\(existingJob.width) and \(job.width))", destination: job.destination, path: "\(existingJob.path) and \(job.path)")
}
}
func createImages() {
var count = 0
for (source, jobs) in imageJobs.sorted(by: { $0.key < $1.key }) {
print(String(format: "Creating images: %4d / %d\r", count, imageJobs.count), terminator: "")
fflush(stdout)
create(images: jobs, from: source)
count += 1
}
print(" \r", terminator: "")
createMultiImages()
optimizeImages()
printMissingImages()
printImageWarnings()
printGeneratedImages()
@ -168,7 +205,10 @@ final class ImageGenerator {
return
}
print("\(missingImages.count) missing images:")
for (source, path) in missingImages {
let sort = missingImages.sorted { (a, b) in
a.value < b.value && a.key < b.key
}
for (source, path) in sort {
print(" \(source) (required by \(path))")
}
}
@ -207,7 +247,7 @@ final class ImageGenerator {
}
private func isMissing(_ job: ImageJob) -> Bool {
!output.appendingPathComponent(job.destination).exists
job.alwaysGenerate || !output.appendingPathComponent(job.destination).exists
}
private func create(images: [ImageJob], from source: String) {
@ -233,6 +273,10 @@ final class ImageGenerator {
private func create(job: ImageJob, from image: NSImage, source: String) {
let destinationUrl = output.appendingPathComponent(job.destination)
create(job: job, from: image, source: source, at: destinationUrl)
}
private func create(job: ImageJob, from image: NSImage, source: String, at destinationUrl: URL) {
// Ensure that image file is supported
let ext = destinationUrl.pathExtension.lowercased()
@ -272,7 +316,7 @@ final class ImageGenerator {
NSGraphicsContext.restoreGraphicsState()
// Get NSData, and save it
guard let data = rep.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
guard let data = rep.representation(using: type, properties: [.compressionFactor: NSNumber(value: job.quality)]) else {
addWarning("Failed to get data", job: job)
return
}
@ -284,4 +328,129 @@ final class ImageGenerator {
}
generatedImages.insert(job.destination)
}
/**
Create images of different types.
This function generates versions for the given image, including png/jpg, avif, and webp. Different pixel density versions (1x and 2x) are also generated.
- Parameter destination: The path to the destination file
*/
@discardableResult
func requireMultiVersionImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int?) -> NSSize {
// Add @1x version
_ = requireScaledMultiImage(source: source, destination: destination, requiredBy: path, width: width, desiredHeight: desiredHeight)
// Add @2x version
return requireScaledMultiImage(
source: source,
destination: destination.insert("@2x", beforeLast: "."),
requiredBy: path,
width: width * 2,
desiredHeight: desiredHeight.unwrapped { $0 * 2 })
}
@discardableResult
private func requireScaledMultiImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int?) -> NSSize {
let rawDestinationPath = destination.dropAfterLast(".")
let avifPath = rawDestinationPath + ".avif"
let webpPath = rawDestinationPath + ".webp"
let needsGeneration = !output.appendingPathComponent(avifPath).exists || !output.appendingPathComponent(webpPath).exists
let size = requireImage(at: destination, generatedFrom: source, requiredBy: path, quality: 1.0, width: width, height: desiredHeight, alwaysGenerate: needsGeneration)
multiImageJobs[destination] = path
return size
}
private func createMultiImages() {
let sort = multiImageJobs.sorted { $0.value < $1.value && $0.key < $1.key }
var count = 1
for (baseImage, path) in sort {
print(String(format: "Creating image versions: %4d / %d\r", count, sort.count), terminator: "")
fflush(stdout)
createMultiImages(from: baseImage, path: path)
count += 1
}
print(" \r", terminator: "")
}
private func createMultiImages(from source: String, path: String) {
guard generatedImages.contains(source) else {
return
}
let sourceUrl = output.appendingPathComponent(source)
let sourcePath = sourceUrl.path
guard sourceUrl.exists else {
addWarning("No image at path \(sourcePath)", destination: source, path: path)
missingImages[source] = path
return
}
let avifPath = source.dropAfterLast(".") + ".avif"
createAVIF(at: output.appendingPathComponent(avifPath).path, from: sourcePath)
generatedImages.insert(avifPath)
let webpPath = source.dropAfterLast(".") + ".webp"
createWEBP(at: output.appendingPathComponent(webpPath).path, from: sourcePath)
generatedImages.insert(webpPath)
compress(at: source)
}
private func createAVIF(at destination: String, from source: String, quality: Int = 55, effort: Int = 5) {
let folder = destination.dropAfterLast("/")
let command = "npx avif --input=\(source) --quality=\(quality) --effort=\(effort) --output=\(folder) --overwrite"
do {
_ = try FileSystem.safeShell(command)
} catch {
addWarning("Failed to create AVIF image", destination: destination, path: destination)
}
}
private func createWEBP(at destination: String, from source: String, quality: Int = 75) {
let command = "cwebp \(source) -q \(quality) -o \(destination)"
do {
_ = try FileSystem.safeShell(command)
} catch {
addWarning("Failed to create WEBP image", destination: destination, path: destination)
}
}
private func compress(at destination: String, quality: Int = 70) {
let command = "magick convert \(destination) -quality \(quality)% \(destination)"
do {
_ = try FileSystem.safeShell(command)
} catch {
addWarning("Failed to compress image", destination: destination, path: destination)
}
}
private func optimizeImages() {
let all = generatedImages
.filter { imageOptimSupportedFileExtensions.contains($0.lastComponentAfter(".")) }
.map { output.appendingPathComponent($0).path }
for i in stride(from: 0, to: all.count, by: imageOptimizationBatchSize) {
let endIndex = min(i+imageOptimizationBatchSize, all.count)
let batch = all[i..<endIndex]
print(String(format: "Optimizing images: %4d / %d\r", endIndex, all.count), terminator: "")
fflush(stdout)
if optimizeImageBatch(batch) {
optimizedImages.formUnion(batch)
}
}
print(" \r", terminator: "")
fflush(stdout)
print("\(optimizedImages.count) images optimized")
}
private func optimizeImageBatch(_ batch: ArraySlice<String>) -> Bool {
let command = "imageoptim " + batch.joined(separator: " ")
do {
_ = try FileSystem.safeShell(command)
return true
} catch {
addWarning("Failed to optimize images", destination: "", path: "")
return false
}
}
}

View File

@ -4,6 +4,8 @@ import AppKit
enum ImageType: CaseIterable {
case jpg
case png
case avif
case webp
init?(fileExtension: String) {
switch fileExtension {
@ -11,6 +13,10 @@ enum ImageType: CaseIterable {
self = .jpg
case "png":
self = .png
case "avif":
self = .avif
case "webp":
self = .webp
default:
return nil
}
@ -20,7 +26,7 @@ enum ImageType: CaseIterable {
switch self {
case .jpg:
return .jpeg
case .png:
case .png, .avif, .webp:
return .png
}
}

View File

@ -46,119 +46,7 @@ final class ValidationLog {
add(info: .init(reason: reason, source: source, error: error))
}
@discardableResult
func unused<T, R>(_ value: Optional<T>, _ name: String, source: String) -> Optional<R> {
if value != nil {
add(info: "Unused property '\(name)'", source: source)
}
return nil
}
func unknown(property: String, source: String) {
add(info: "Unknown property '\(property)'", source: source)
}
func required<T>(_ value: Optional<T>, name: String, source: String) -> Optional<T> {
guard let value = value else {
add(error: "Missing property '\(name)'", source: source)
return nil
}
return value
}
func unexpected<T>(_ value: Optional<T>, name: String, source: String) -> Optional<T> {
if let value = value {
add(error: "Unexpected property '\(name)' = '\(value)'", source: source)
return nil
}
return nil
}
func missing(_ file: String, requiredBy source: String) {
print("[ERROR] Missing file '\(file)' required by \(source)")
}
func failedToOpen(_ file: String, requiredBy source: String, error: Error?) {
print("[ERROR] Failed to open file '\(file)' required by \(source): \(error?.localizedDescription ?? "No error provided")")
}
func state(_ raw: String?, source: String) -> PageState {
guard let raw = raw else {
return .standard
}
guard let state = PageState(rawValue: raw) else {
add(warning: "Invalid 'state' '\(raw)', using 'standard'", source: source)
return .standard
}
return state
}
func headerType(_ raw: String?, source: String) -> HeaderType {
guard let raw = raw else {
return .left
}
guard let type = HeaderType(rawValue: raw) else {
add(warning: "Invalid 'headerType' '\(raw)', using 'left'", source: source)
return .left
}
return type
}
func thumbnailStyle(_ raw: String?, source: String) -> ThumbnailStyle {
guard let raw = raw else {
return .large
}
guard let style = ThumbnailStyle(rawValue: raw) else {
add(warning: "Invalid 'thumbnailStyle' '\(raw)', using 'large'", source: source)
return .large
}
return style
}
func linkPreviewThumbnail(customFile: String?, for language: String, in folder: URL, source: String) -> String? {
guard let customFile = customFile else {
return nil
}
let customFileUrl: URL
if customFile.starts(with: "/") {
customFileUrl = URL(fileURLWithPath: customFile)
} else {
customFileUrl = folder.appendingPathComponent(customFile)
}
guard customFileUrl.exists else {
missing(customFile, requiredBy: "property 'linkPreviewImage' in metadata of \(source)")
return nil
}
return customFile
}
func moreLinkText(_ elementText: String?, parent parentText: String?, source: String) -> String {
if let elementText = elementText {
return elementText
}
let standard = Element.LocalizedMetadata.moreLinkDefaultText
guard let parentText = parentText, parentText != standard else {
add(error: "Missing property 'moreLinkText'", source: source)
return standard
}
return parentText
}
func date(from string: String?, property: String, source: String) -> Date? {
guard let string = string else {
return nil
}
guard let date = ValidationLog.metadataDate.date(from: string) else {
add(warning: "Invalid date string '\(string)' for property '\(property)'", source: source)
return nil
}
return date
}
private static let metadataDate: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yy"
return df
}()
}

View File

@ -15,6 +15,21 @@ struct OverviewSectionGenerator {
}
func generate(sections: [Element], in parent: Element, language: String, sectionItemCount: Int) -> String {
let content = sectionsContent(sections, in: parent, language: language, sectionItemCount: sectionItemCount)
if parent.showMostRecentSection {
let news = newsSectionContent(for: parent, language: language, sectionItemCount: sectionItemCount)
return news + "\n" + content
} else {
return content
}
}
private func newsSectionContent(for element: Element, language: String, sectionItemCount: Int) -> String {
let shownElements = element.mostRecentElements(sectionItemCount)
return ""
}
private func sectionsContent(_ sections: [Element], in parent: Element, language: String, sectionItemCount: Int) -> String {
sections.map { section in
let metadata = section.localized(for: language)
let fullUrl = section.fullPageUrl(for: language)

View File

@ -8,8 +8,11 @@ struct PageContentGenerator {
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
init(factory: TemplateFactory) {
private let siteRoot: Element
init(factory: TemplateFactory, siteRoot: Element) {
self.factory = factory
self.siteRoot = siteRoot
}
func generate(page: Element, language: String, content: String) -> (content: String, includesCode: Bool) {
@ -120,23 +123,14 @@ struct PageContentGenerator {
private func handleImage(page: Element, file: String, rightTitle: String?, leftTitle: String?) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
let size = files.requireImage(
let size = files.requireFullSizeMultiVersionImage(
source: imagePath,
destination: imagePath,
requiredBy: page.path,
width: configuration.pageImageWidth)
let imagePath2x = imagePath.insert("@2x", beforeLast: ".")
let file2x = file.insert("@2x", beforeLast: ".")
files.requireImage(
source: imagePath,
destination: imagePath2x,
requiredBy: page.path,
width: 2 * configuration.pageImageWidth)
requiredBy: page.path)
let content: [PageImageTemplate.Key : String] = [
.image: file,
.image2x: file2x,
.image: file.dropAfterLast("."),
.imageExtension: file.lastComponentAfter("."),
.width: "\(Int(size.width))",
.height: "\(Int(size.height))",
.leftText: leftTitle ?? "",
@ -261,7 +255,6 @@ struct PageContentGenerator {
return ""
}
var content = [PageLinkTemplate.Key: String]()
content[.url] = page.relativePathToOtherSiteElement(file: linkedPage.fullPageUrl(for: language))
content[.title] = linkedPage.title(for: language)
@ -275,13 +268,12 @@ struct PageContentGenerator {
content[.url] = "href=\"\(relativePageUrl)\""
}
content[.image] = relativeImageUrl
content[.image] = relativeImageUrl.dropAfterLast(".")
if let suffix = metadata.thumbnailSuffix {
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
} else {
content[.title] = metadata.title
}
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
let path = linkedPage.makePath(language: language, from: siteRoot)
content[.path] = factory.pageLink.makePath(components: path)

View File

@ -5,8 +5,11 @@ struct PageGenerator {
private let factory: LocalizedSiteTemplate
init(factory: LocalizedSiteTemplate) {
private let contentGenerator: PageContentGenerator
init(factory: LocalizedSiteTemplate, siteRoot: Element) {
self.factory = factory
self.contentGenerator = PageContentGenerator(factory: factory.factory, siteRoot: siteRoot)
}
func generate(page: Element, language: String, previousPage: Element?, nextPage: Element?) {
@ -73,15 +76,11 @@ struct PageGenerator {
}
private func makeContent(page: Element, metadata: Element.LocalizedMetadata, language: String, path: String) -> (content: String, includesCode: Bool, isEmpty: Bool) {
let create = configuration.createMdFilesIfMissing
if let raw = files.contentOfOptionalFile(atPath: path, source: page.path, createEmptyFileIfMissing: create)?
.trimmed.nonEmpty {
let (content, includesCode) = PageContentGenerator(factory: factory.factory)
.generate(page: page, language: language, content: raw)
if let raw = files.contentOfMdFile(atPath: path, source: page.path)?.trimmed.nonEmpty {
let (content, includesCode) = contentGenerator.generate(page: page, language: language, content: raw)
return (content, includesCode, false)
} else {
let (content, includesCode) = PageContentGenerator(factory: factory.factory)
.generate(page: page, language: language, content: metadata.placeholderText)
let (content, includesCode) = contentGenerator.generate(page: page, language: language, content: metadata.placeholderText)
let placeholder = factory.factory.makePlaceholder(title: metadata.placeholderTitle, text: content)
return (placeholder, includesCode, true)
}

View File

@ -24,7 +24,7 @@ struct PageHeadGenerator {
let linkPreviewImageName = "thumbnail-link.\(image.lastComponentAfter("."))"
let sourceImagePath = page.pathRelativeToRootForContainedInputFile(image)
let destinationImagePath = page.pathRelativeToRootForContainedInputFile(linkPreviewImageName)
files.requireImage(
files.requireSingleImage(
source: sourceImagePath,
destination: destinationImagePath,
requiredBy: page.path,

View File

@ -26,7 +26,7 @@ struct SiteGenerator {
// Generate sections
let overviewGenerator = OverviewPageGenerator(factory: template)
let pageGenerator = PageGenerator(factory: template)
let pageGenerator = PageGenerator(factory: template, siteRoot: site)
var elementsToProcess: [LinkedElement] = [(nil, site, nil)]
while let (previous, element, next) = elementsToProcess.popLast() {
@ -51,7 +51,7 @@ struct SiteGenerator {
element.requiredFiles.forEach(files.require)
element.externalFiles.forEach(files.exclude)
element.images.forEach {
files.requireImage(
files.requireSingleImage(
source: $0.sourcePath,
destination: $0.destinationPath,
requiredBy: element.path,

View File

@ -14,8 +14,7 @@ struct ThumbnailListGenerator {
}
private func itemContent(_ item: Element, parent: Element, language: String, style: ThumbnailStyle) -> String {
let (thumbnailSourcePath, thumbnailDestPath) = item.thumbnailFilePath(for: language)
let relativeImageUrl = parent.relativePathToFileWithPath(thumbnailDestPath)
let metadata = item.localized(for: language)
var content = [ThumbnailKey : String]()
@ -25,32 +24,26 @@ struct ThumbnailListGenerator {
content[.url] = "href=\"\(relativePageUrl)\""
}
content[.image] = relativeImageUrl
let (thumbnailSourcePath, thumbnailDestPath) = item.thumbnailFilePath(for: language)
let thumbnailDestNoExtension = thumbnailDestPath.dropAfterLast(".")
content[.image] = parent.relativePathToFileWithPath(thumbnailDestNoExtension)
if style == .large, let suffix = metadata.thumbnailSuffix {
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
} else {
content[.title] = metadata.title
}
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
content[.corner] = item.cornerText(for: language).unwrapped {
factory.largeThumbnail.makeCorner(text: $0)
}
files.requireImage(
files.requireMultiVersionImage(
source: thumbnailSourcePath,
destination: thumbnailDestPath,
destination: thumbnailDestNoExtension + ".jpg",
requiredBy: item.path,
width: style.width,
desiredHeight: style.height)
// Create image version for high-resolution screens
files.requireImage(
source: thumbnailSourcePath,
destination: thumbnailDestPath.insert("@2x", beforeLast: "."),
requiredBy: item.path,
width: style.width * 2,
desiredHeight: style.height * 2)
return factory.thumbnail(style: style).generate(content, shouldIndent: false)
}
}

View File

@ -0,0 +1,178 @@
import Foundation
final class MetadataInfoLogger {
private let input: URL
private var numberOfMetadataFiles = 0
private var unusedProperties: [(name: String, source: String)] = []
private var invalidProperties: [(name: String, source: String, reason: String)] = []
private var unknownProperties: [(name: String, source: String)] = []
private var missingProperties: [(name: String, source: String)] = []
private var unreadableMetadata: [(source: String, error: Error)] = []
private var warnings: [(source: String, message: String)] = []
private var errors: [(source: String, message: String)] = []
init(input: URL) {
self.input = input
}
/**
Adds an info message if a value is set for an unused property.
- Note: Unused properties do not cause an element to be skipped.
*/
@discardableResult
func unused<T>(_ value: Optional<T>, _ name: String, source: String) -> T where T: DefaultValueProvider {
if let value {
unusedProperties.append((name, source))
return value
}
return T.defaultValue
}
/**
Cast a string value to another value, and using a default in case of errors.
- Note: Invalid string values do not cause an element to be skipped.
*/
func cast<T>(_ value: String, _ name: String, source: String) -> T where T: DefaultValueProvider, T: StringProperty {
guard let result = T.init(value) else {
invalidProperties.append((name: name, source: source, reason: T.castFailureReason))
return T.defaultValue
}
return result
}
/**
Cast a string value to another value, and using a default in case of errors or missing values.
- Note: Invalid string values do not cause an element to be skipped.
*/
func cast<T>(_ value: String?, _ name: String, source: String) -> T where T: DefaultValueProvider, T: StringProperty {
guard let value else {
return T.defaultValue
}
guard let result = T.init(value) else {
invalidProperties.append((name: name, source: source, reason: T.castFailureReason))
return T.defaultValue
}
return result
}
/**
Cast the string value of an unused property to another value, and using a default in case of errors.
- Note: Invalid string values do not cause an element to be skipped.
*/
func castUnused<R>(_ value: String?, _ name: String, source: String) -> R where R: DefaultValueProvider, R: StringProperty {
unused(value.unwrapped { cast($0, name, source: source) }, name, source: source)
}
/**
Note an unknown property.
- Note: Unknown properties do not cause an element to be skipped.
*/
func unknown(property: String, source: String) {
unknownProperties.append((name: property, source: source))
}
/**
Ensure that a property is set, and aborting metadata decoding.
- Note: Missing required properties cause an element to be skipped.
*/
func required<T>(_ value: T?, name: String, source: String, _ valid: inout Bool) -> T where T: DefaultValueProvider {
guard let value = value else {
missingProperties.append((name, source))
valid = false
return T.defaultValue
}
return value
}
func warning(_ message: String, source: String) {
warnings.append((source, message))
}
func error(_ message: String, source: String) {
errors.append((source, message))
}
func failedToDecodeMetadata(source: String, error: Error) {
unreadableMetadata.append((source, error))
}
func readPotentialMetadata(atPath path: String, source: String) -> Data? {
let url = input.appendingPathComponent(path)
guard url.exists else {
return nil
}
numberOfMetadataFiles += 1
printMetadataScanUpdate()
do {
return try Data(contentsOf: url)
} catch {
unreadableMetadata.append((source, error))
return nil
}
}
// MARK: Printing
private func printMetadataScanUpdate() {
print(String(format: "Scanning source files: %4d pages found \r", numberOfMetadataFiles), terminator: "")
}
func printMetadataScanOverview() {
var notes = [String]()
func addIfNotZero<S>(_ sequence: Array<S>, _ name: String) {
guard sequence.count > 0 else {
return
}
notes.append("\(sequence.count) \(name)")
}
addIfNotZero(warnings, "warnings")
addIfNotZero(errors, "errors")
addIfNotZero(unreadableMetadata, "unreadable files")
addIfNotZero(unusedProperties, "unused properties")
addIfNotZero(invalidProperties, "invalidProperties")
addIfNotZero(unknownProperties, "unknownProperties")
addIfNotZero(missingProperties, "missingProperties")
print(" Number of pages: \(numberOfMetadataFiles)")
print(" Notes: " + notes.joined(separator: ", "))
}
func writeResultsToFile(in folder: URL) throws {
let url = folder.appendingPathComponent("Metadata issues.txt")
var lines: [String] = []
if !errors.isEmpty {
lines += ["Errors:"] + errors.map { "\($0.source): \($0.message)" }
}
if !warnings.isEmpty {
lines += ["Warnings:"] + warnings.map { "\($0.source): \($0.message)" }
}
if !unreadableMetadata.isEmpty {
lines += ["Unreadable files:"] + unreadableMetadata.map { "\($0.source): \($0.error)" }
}
if !unusedProperties.isEmpty {
lines += ["Unused properties:"] + unusedProperties.map { "\($0.source): \($0.name)" }
}
if !invalidProperties.isEmpty {
lines += ["Invalid properties:"] + invalidProperties.map { "\($0.source): \($0.name) (\($0.reason))" }
}
if !unknownProperties.isEmpty {
lines += ["Unknown properties:"] + unknownProperties.map { "\($0.source): \($0.name)" }
}
if !missingProperties.isEmpty {
lines += ["Missing properties:"] + missingProperties.map { "\($0.source): \($0.name)" }
}
let data = lines.joined(separator: "\n").data(using: .utf8)
try data?.createFolderAndWrite(to: url)
}
}

View File

@ -4,7 +4,7 @@ struct PageImageTemplate: Template {
enum Key: String, CaseIterable {
case image = "IMAGE"
case image2x = "IMAGE_2X"
case imageExtension = "IMAGE_EXT"
case width = "WIDTH"
case height = "HEIGHT"
case leftText = "LEFT_TEXT"

View File

@ -5,7 +5,6 @@ struct PageLinkTemplate: Template {
enum Key: String, CaseIterable {
case url = "URL"
case image = "IMAGE"
case image2x = "IMAGE_2X"
case title = "TITLE"
case path = "PATH"
case description = "DESCRIPTION"

View File

@ -8,7 +8,6 @@ protocol ThumbnailTemplate {
enum ThumbnailKey: String, CaseIterable {
case url = "URL"
case image = "IMAGE"
case image2x = "IMAGE_2X"
case title = "TITLE"
case corner = "CORNER"
}

View File

@ -1,10 +1,8 @@
import Foundation
import ArgumentParser
var configuration: Configuration!
let log = ValidationLog()
var files: FileSystem!
var siteRoot: Element!
@main
struct CHGenerator: ParsableCommand {
@ -17,17 +15,48 @@ struct CHGenerator: ParsableCommand {
}
}
private func generate(configPath: String) throws {
private func loadSiteData(in folder: URL) throws -> Element? {
let log = MetadataInfoLogger(input: folder)
print("--- SOURCE FILES -----------------------------------")
let root = Element(atRoot: folder, log: log)
print(" ")
log.printMetadataScanOverview()
print(" ")
try log.writeResultsToFile(in: files.generatorInfoFolder)
return root
}
private func loadConfiguration(at configPath: String) -> Configuration? {
print("--- CONFIGURATION ----------------------------------")
print("")
print(" Configuration file: \(configPath)")
let configUrl = URL(fileURLWithPath: configPath)
let data = try Data(contentsOf: configUrl)
configuration = try JSONDecoder().decode(from: data)
let config: Configuration
do {
let data = try Data(contentsOf: configUrl)
config = try JSONDecoder().decode(from: data)
} catch {
print(" Configuration error: \(error)")
return nil
}
config.printOverview()
print(" ")
return config
}
private func generate(configPath: String) throws {
guard let configuration = loadConfiguration(at: configPath) else {
return
}
files = .init(
in: configuration.contentDirectory,
to: configuration.outputDirectory)
to: configuration.outputDirectory,
configuration: configuration)
siteRoot = Element(atRoot: configuration.contentDirectory)
guard siteRoot != nil else {
// 2. Scan site elements
guard let siteRoot = try loadSiteData(in: configuration.contentDirectory) else {
return
}
let siteGenerator = try SiteGenerator()

View File

@ -11,3 +11,6 @@ npm install uglify-js -g
# Install the clean-css minifier
# https://github.com/clean-css/clean-css-cli
npm install clean-css-cli -g
# Required to optimize jpg/png/svg
npm install imageoptim-cli -g