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 - 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. element in the path that defines this property.
*/ */
let moreLinkText: String let moreLinkText: String?
/** /**
The text on the back navigation link of **contained** elements. The text on the back navigation link of **contained** elements.
@ -145,77 +145,52 @@ extension Element {
extension Element.LocalizedMetadata { 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 // Go through all elements and check them for completeness
// In the end, check that all required elements are present // In the end, check that all required elements are present
var isComplete = true var isValid = true
func markAsIncomplete() {
isComplete = false
}
let source = "root" let source = "root"
self.language = log self.language = log.required(data.language, name: "language", source: source, &isValid)
.required(data.language, name: "language", source: source) self.title = log.required(data.title, name: "title", source: source, &isValid)
.ifNil(markAsIncomplete) ?? ""
self.title = log
.required(data.title, name: "title", source: source)
.ifNil(markAsIncomplete) ?? ""
self.subtitle = data.subtitle self.subtitle = data.subtitle
self.description = data.description self.description = data.description
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? "" self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
self.linkPreviewImage = log self.linkPreviewImage = data.linkPreviewImage
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle
self.linkPreviewDescription = log self.linkPreviewDescription = log.required(linkPreviewDescription, name: "linkPreviewDescription", source: source, &isValid)
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source) self.moreLinkText = data.moreLinkText
.ifNil(markAsIncomplete) ?? "" self.backLinkText = log.required(data.backLinkText, name: "backLinkText", source: source, &isValid)
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
self.backLinkText = log
.required(data.backLinkText, name: "backLinkText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.parentBackLinkText = "" // Root has no parent self.parentBackLinkText = "" // Root has no parent
self.placeholderTitle = log self.placeholderTitle = log.required(data.placeholderTitle, name: "placeholderTitle", source: source, &isValid)
.required(data.placeholderTitle, name: "placeholderTitle", source: source) self.placeholderText = log.required(data.placeholderText, name: "placeholderText", source: source, &isValid)
.ifNil(markAsIncomplete) ?? ""
self.placeholderText = log
.required(data.placeholderText, name: "placeholderText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.titleSuffix = data.titleSuffix self.titleSuffix = data.titleSuffix
self.thumbnailSuffix = log.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source) self.thumbnailSuffix = log.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
self.cornerText = log.unused(data.cornerText, "cornerText", source: source) self.cornerText = log.unused(data.cornerText, "cornerText", source: source)
self.externalUrl = log.unexpected(data.externalUrl, name: "externalUrl", source: source) self.externalUrl = log.unused(data.externalUrl, "externalUrl", source: source)
self.relatedContentText = log self.relatedContentText = log.required(data.relatedContentText, name: "relatedContentText", source: source, &isValid)
.required(data.relatedContentText, name: "relatedContentText", source: source) ?? "" self.navigationTextAsNextPage = log.required(data.navigationTextAsNextPage, name: "navigationTextAsNextPage", source: source, &isValid)
self.navigationTextAsNextPage = log self.navigationTextAsPreviousPage = log.required(data.navigationTextAsPreviousPage, name: "navigationTextAsPreviousPage", source: source, &isValid)
.required(data.navigationTextAsNextPage, name: "navigationTextAsNextPage", source: source) ?? ""
self.navigationTextAsPreviousPage = log
.required(data.navigationTextAsPreviousPage, name: "navigationTextAsPreviousPage", source: source) ?? ""
guard isComplete else { guard isValid else {
return nil 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 // Go through all elements and check them for completeness
// In the end, check that all required elements are present // In the end, check that all required elements are present
var isComplete = true var isValid = true
func markAsIncomplete() {
isComplete = false
}
self.language = parent.language self.language = parent.language
self.title = log self.title = log.required(data.title, name: "title", source: source, &isValid)
.required(data.title, name: "title", source: source)
.ifNil(markAsIncomplete) ?? ""
self.subtitle = data.subtitle self.subtitle = data.subtitle
self.description = data.description self.description = data.description
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? "" self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
self.linkPreviewImage = log self.linkPreviewImage = data.linkPreviewImage
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle
self.linkPreviewDescription = log self.linkPreviewDescription = log.required(linkPreviewDescription, name: "linkPreviewDescription", source: source, &isValid)
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source) self.moreLinkText = log.required(data.moreLinkText ?? parent.moreLinkText, name: "moreLinkText", source: source, &isValid)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = log.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
self.backLinkText = data.backLinkText ?? data.title ?? "" self.backLinkText = data.backLinkText ?? data.title ?? ""
self.parentBackLinkText = parent.backLinkText self.parentBackLinkText = parent.backLinkText
self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle
@ -228,7 +203,7 @@ extension Element.LocalizedMetadata {
self.navigationTextAsPreviousPage = data.navigationTextAsPreviousPage ?? parent.navigationTextAsPreviousPage self.navigationTextAsPreviousPage = data.navigationTextAsPreviousPage ?? parent.navigationTextAsPreviousPage
self.navigationTextAsNextPage = data.navigationTextAsNextPage ?? parent.navigationTextAsNextPage self.navigationTextAsNextPage = data.navigationTextAsNextPage ?? parent.navigationTextAsNextPage
guard isComplete else { guard isValid else {
return nil return nil
} }
} }

View File

@ -126,6 +126,12 @@ struct Element {
*/ */
let headerType: HeaderType 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. The localized metadata for each language.
*/ */
@ -157,122 +163,130 @@ struct Element {
- Parameter folder: The root folder of the site content. - Parameter folder: The root folder of the site content.
- Note: Uses global objects. - Note: Uses global objects.
*/ */
init?(atRoot folder: URL) { init?(atRoot folder: URL, log: MetadataInfoLogger) {
self.inputFolder = folder self.inputFolder = folder
self.path = "" self.path = ""
let source = GenericMetadata.metadataFileName let source = GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else { guard let metadata = GenericMetadata(source: source, log: log) else {
return nil return nil
} }
var isValid = true
self.id = metadata.customId ?? Element.defaultRootId self.id = metadata.customId ?? Element.defaultRootId
self.author = log.required(metadata.author, name: "author", source: source) ?? "author" self.author = log.required(metadata.author, name: "author", source: source, &isValid)
self.topBarTitle = log self.topBarTitle = log.required(metadata.topBarTitle, name: "topBarTitle", source: source, &isValid)
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website" self.date = log.castUnused(metadata.date, "date", source: source)
self.date = log.unused(metadata.date, "date", source: source) self.endDate = log.castUnused(metadata.endDate, "endDate", source: source)
self.endDate = log.unused(metadata.endDate, "endDate", source: source) self.state = log.cast(metadata.state, "state", source: source)
self.state = log.state(metadata.state, source: source)
self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source) self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source)
self.externalFiles = metadata.externalFiles ?? [] self.externalFiles = metadata.externalFiles ?? []
self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: "") } ?? [] self.images = metadata.images?.compactMap { ManualImage(input: $0, path: "") } ?? []
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
self.thumbnailStyle = log.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large self.thumbnailStyle = log.castUnused(metadata.thumbnailStyle, "thumbnailStyle", source: source)
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source)
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
self.headerType = log.headerType(metadata.headerType, source: source) self.headerType = log.cast(metadata.headerType, "headerType", source: source)
self.languages = log.required(metadata.languages, name: "languages", source: source)? self.showMostRecentSection = metadata.showMostRecentSection ?? false
self.languages = log.required(metadata.languages, name: "languages", source: source, &isValid)
.compactMap { language in .compactMap { language in
.init(atRoot: folder, data: language) .init(atRoot: folder, data: language, log: log)
} ?? [] }
// All properties initialized // All properties initialized
guard !languages.isEmpty else { 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 return nil
} }
files.add(page: path, id: id) 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] let subFolders: [URL]
do { do {
subFolders = try FileManager.default subFolders = try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey]) .contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.isDirectory } .filter { $0.isDirectory }
} catch { } catch {
log.add(error: "Failed to read subfolders", source: source ?? "root", error: error) log.error("Failed to read subfolders: \(error)", source: source ?? "root")
return return
} }
self.elements = subFolders.compactMap { subFolder in self.elements = subFolders.compactMap { subFolder in
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent 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.inputFolder = folder
self.path = path self.path = path
let source = path + "/" + GenericMetadata.metadataFileName let source = path + "/" + GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else { guard let metadata = GenericMetadata(source: source, log: log) else {
return nil return nil
} }
var isValid = true
self.id = metadata.customId ?? folder.lastPathComponent self.id = metadata.customId ?? folder.lastPathComponent
self.author = metadata.author ?? parent.author self.author = metadata.author ?? parent.author
self.topBarTitle = log self.topBarTitle = log.unused(metadata.topBarTitle, "topBarTitle", source: source)
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle self.date = metadata.date.unwrapped { log.cast($0, "date", source: source) }
let date = log.date(from: metadata.date, property: "date", source: source).ifNil { self.endDate = metadata.date.unwrapped { log.cast($0, "endDate", source: source) }
if !parent.useManualSorting { self.state = log.cast(metadata.state, "state", source: source)
log.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source) self.sortIndex = metadata.sortIndex
}
}
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)
}
}
// TODO: Propagate external files from the parent if subpath matches? // TODO: Propagate external files from the parent if subpath matches?
self.externalFiles = Element.rootPaths(for: metadata.externalFiles, path: path) self.externalFiles = Element.rootPaths(for: metadata.externalFiles, path: path)
self.requiredFiles = Element.rootPaths(for: metadata.requiredFiles, path: path) self.requiredFiles = Element.rootPaths(for: metadata.requiredFiles, path: path)
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: path) } ?? [] self.images = metadata.images?.compactMap { ManualImage(input: $0, path: path) } ?? []
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName 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.useManualSorting = metadata.useManualSorting ?? false
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount 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 self.languages = parent.languages.compactMap { parentData in
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else { 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 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 // Check that each 'language' tag is present, and that all languages appear in the parent
log.required(metadata.languages, name: "languages", source: source)? log.required(metadata.languages, name: "languages", source: source, &isValid)
.compactMap { log.required($0.language, name: "language", source: source) } .compactMap { log.required($0.language, name: "language", source: source, &isValid) }
.filter { language in .filter { language in
!parent.languages.contains { $0.language == language } !parent.languages.contains { $0.language == language }
} }
.forEach { .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 // 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) 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] { var sortedItems: [Element] {
if useManualSorting { if useManualSorting {
return shownItems.sorted { $0.sortIndex! < $1.sortIndex! } 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, // The relative path needs to go down to the first common folder,
// before going up to the target page // before going up to the target page
let allParts = [String](repeating: "..", count: ownParts.count-index) let allParts = [String](repeating: "..", count: ownParts.count-index)
+ pageParts.dropFirst(index) + pageParts.dropFirst(index)
return allParts.joined(separator: "/") return allParts.joined(separator: "/")
} }

View File

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

View File

@ -125,6 +125,12 @@ struct GenericMetadata {
*/ */
let headerType: String? 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. The localized metadata for each language.
*/ */
@ -150,6 +156,7 @@ extension GenericMetadata: Codable {
.useManualSorting, .useManualSorting,
.overviewItemCount, .overviewItemCount,
.headerType, .headerType,
.showMostRecentSection,
.languages, .languages,
] ]
} }
@ -169,8 +176,8 @@ extension GenericMetadata {
- Note: The decoding routine also checks for unknown properties, and writes them to the output. - Note: The decoding routine also checks for unknown properties, and writes them to the output.
- Note: Uses global objects - Note: Uses global objects
*/ */
init?(source: String) { init?(source: String, log: MetadataInfoLogger) {
guard let data = files.dataOfOptionalFile(atPath: source, source: source) else { guard let data = log.readPotentialMetadata(atPath: source, source: source) else {
return nil return nil
} }
@ -196,8 +203,7 @@ extension GenericMetadata {
do { do {
self = try decoder.decode(from: data) self = try decoder.decode(from: data)
} catch { } catch {
print("Here \(data)") log.failedToDecodeMetadata(source: source, error: error)
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
return nil return nil
} }
} }
@ -222,6 +228,7 @@ extension GenericMetadata {
useManualSorting: false, useManualSorting: false,
overviewItemCount: 6, overviewItemCount: 6,
headerType: "left", headerType: "left",
showMostRecentSection: false,
languages: [.full]) languages: [.full])
} }
} }

View File

@ -17,3 +17,19 @@ enum HeaderType: String {
*/ */
case none 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: 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 { extension Optional {
func unwrapped<T>(_ closure: (Wrapped) -> T) -> T? { func unwrapped<T>(_ closure: (Wrapped) -> T?) -> T? {
if case let .some(value) = self { if case let .some(value) = self {
return closure(value) return closure(value)
} }

View File

@ -20,6 +20,11 @@ extension String {
.joined(separator: "\n") .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 { func dropAfterLast(_ separator: String) -> String {
guard contains(separator) else { guard contains(separator) else {
return self return self
@ -74,3 +79,8 @@ extension String {
try data(using: .utf8)!.createFolderAndWrite(to: url) 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 { var outputDirectory: URL {
.init(fileURLWithPath: outputPath) .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 images: ImageGenerator
private let configuration: Configuration
private var tempFile: URL { private var tempFile: URL {
input.appendingPathComponent(FileSystem.tempFileName) input.appendingPathComponent(FileSystem.tempFileName)
} }
let generatorInfoFolder: URL
/** /**
All files which should be copied to the output folder All files which should be copied to the output folder
*/ */
@ -66,11 +70,12 @@ final class FileSystem {
*/ */
private var generatedPages: Set<String> = [] 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.input = input
self.output = output self.output = output
self.images = .init(input: input, output: output) self.images = .init(input: input, output: output)
self.generatorInfoFolder = input.appendingPathComponent("run")
self.configuration = configuration
} }
func urlInOutputFolder(_ path: String) -> URL { func urlInOutputFolder(_ path: String) -> URL {
@ -100,18 +105,8 @@ final class FileSystem {
} }
} }
func dataOfOptionalFile(atPath path: String, source: String) -> Data? { func contentOfMdFile(atPath path: String, source: String) -> String? {
let url = input.appendingPathComponent(path) contentOfOptionalFile(atPath: path, source: source, createEmptyFileIfMissing: configuration.createMdFilesIfMissing)
guard exists(url) else {
return nil
}
do {
return try Data(contentsOf: url)
} catch {
log.failedToOpen(path, requiredBy: source, error: error)
return nil
}
} }
func contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? { func contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
@ -138,8 +133,30 @@ final class FileSystem {
// MARK: Images // MARK: Images
@discardableResult @discardableResult
func requireImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize { func requireSingleImage(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) 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() { func createImages() {
@ -252,7 +269,7 @@ final class FileSystem {
private func minifyJS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool { private func minifyJS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool {
let command = "uglifyjs \(sourceUrl.path) > \(tempFile.path)" let command = "uglifyjs \(sourceUrl.path) > \(tempFile.path)"
do { do {
_ = try safeShell(command) _ = try FileSystem.safeShell(command)
return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl) return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl)
} catch { } catch {
log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source) 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 { private func minifyCSS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool {
let command = "cleancss \(sourceUrl.path) -o \(tempFile.path)" let command = "cleancss \(sourceUrl.path) -o \(tempFile.path)"
do { do {
_ = try safeShell(command) _ = try FileSystem.safeShell(command)
return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl) return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl)
} catch { } catch {
log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source) log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source)
@ -406,7 +423,7 @@ final class FileSystem {
// MARK: Running other tasks // MARK: Running other tasks
@discardableResult @discardableResult
func safeShell(_ command: String) throws -> String { static func safeShell(_ command: String) throws -> String {
let task = Process() let task = Process()
let pipe = Pipe() let pipe = Pipe()

View File

@ -1,6 +1,7 @@
import Foundation import Foundation
import AppKit import AppKit
import CryptoKit import CryptoKit
import Darwin.C
private struct ImageJob { private struct ImageJob {
@ -9,10 +10,18 @@ private struct ImageJob {
let width: Int let width: Int
let path: String let path: String
let quality: Float
let alwaysGenerate: Bool
} }
final class ImageGenerator { final class ImageGenerator {
private let imageOptimSupportedFileExtensions: Set<String> = ["jpg", "png", "svg"]
private let imageOptimizationBatchSize = 50
/** /**
The path to the input folder. The path to the input folder.
*/ */
@ -30,6 +39,13 @@ final class ImageGenerator {
*/ */
private var imageJobs: [String : [ImageJob]] = [:] 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. 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> = [] 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. 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) requiredImages.insert(destination)
let height = height.unwrapped(CGFloat.init) let height = height.unwrapped(CGFloat.init)
let sourceUrl = input.appendingPathComponent(source) 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 return scaledSize
} }
func createImages() { private func insert(job: ImageJob, source: String) {
for (source, jobs) in imageJobs.sorted(by: { $0.key < $1.key }) { guard let existingSource = imageJobs[source] else {
create(images: jobs, from: source) 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() printMissingImages()
printImageWarnings() printImageWarnings()
printGeneratedImages() printGeneratedImages()
@ -168,7 +205,10 @@ final class ImageGenerator {
return return
} }
print("\(missingImages.count) missing images:") 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))") print(" \(source) (required by \(path))")
} }
} }
@ -207,7 +247,7 @@ final class ImageGenerator {
} }
private func isMissing(_ job: ImageJob) -> Bool { 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) { 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) { private func create(job: ImageJob, from image: NSImage, source: String) {
let destinationUrl = output.appendingPathComponent(job.destination) 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 // Ensure that image file is supported
let ext = destinationUrl.pathExtension.lowercased() let ext = destinationUrl.pathExtension.lowercased()
@ -272,7 +316,7 @@ final class ImageGenerator {
NSGraphicsContext.restoreGraphicsState() NSGraphicsContext.restoreGraphicsState()
// Get NSData, and save it // 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) addWarning("Failed to get data", job: job)
return return
} }
@ -284,4 +328,129 @@ final class ImageGenerator {
} }
generatedImages.insert(job.destination) 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 { enum ImageType: CaseIterable {
case jpg case jpg
case png case png
case avif
case webp
init?(fileExtension: String) { init?(fileExtension: String) {
switch fileExtension { switch fileExtension {
@ -11,6 +13,10 @@ enum ImageType: CaseIterable {
self = .jpg self = .jpg
case "png": case "png":
self = .png self = .png
case "avif":
self = .avif
case "webp":
self = .webp
default: default:
return nil return nil
} }
@ -20,7 +26,7 @@ enum ImageType: CaseIterable {
switch self { switch self {
case .jpg: case .jpg:
return .jpeg return .jpeg
case .png: case .png, .avif, .webp:
return .png return .png
} }
} }

View File

@ -46,119 +46,7 @@ final class ValidationLog {
add(info: .init(reason: reason, source: source, error: error)) 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?) { func failedToOpen(_ file: String, requiredBy source: String, error: Error?) {
print("[ERROR] Failed to open file '\(file)' required by \(source): \(error?.localizedDescription ?? "No error provided")") 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 { 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 sections.map { section in
let metadata = section.localized(for: language) let metadata = section.localized(for: language)
let fullUrl = section.fullPageUrl(for: language) let fullUrl = section.fullPageUrl(for: language)

View File

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

View File

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

View File

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

View File

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

View File

@ -14,8 +14,7 @@ struct ThumbnailListGenerator {
} }
private func itemContent(_ item: Element, parent: Element, language: String, style: ThumbnailStyle) -> String { 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) let metadata = item.localized(for: language)
var content = [ThumbnailKey : String]() var content = [ThumbnailKey : String]()
@ -25,32 +24,26 @@ struct ThumbnailListGenerator {
content[.url] = "href=\"\(relativePageUrl)\"" 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 { if style == .large, let suffix = metadata.thumbnailSuffix {
content[.title] = factory.html.make(title: metadata.title, suffix: suffix) content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
} else { } else {
content[.title] = metadata.title content[.title] = metadata.title
} }
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
content[.corner] = item.cornerText(for: language).unwrapped { content[.corner] = item.cornerText(for: language).unwrapped {
factory.largeThumbnail.makeCorner(text: $0) factory.largeThumbnail.makeCorner(text: $0)
} }
files.requireImage( files.requireMultiVersionImage(
source: thumbnailSourcePath, source: thumbnailSourcePath,
destination: thumbnailDestPath, destination: thumbnailDestNoExtension + ".jpg",
requiredBy: item.path, requiredBy: item.path,
width: style.width, width: style.width,
desiredHeight: style.height) 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) 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 { enum Key: String, CaseIterable {
case image = "IMAGE" case image = "IMAGE"
case image2x = "IMAGE_2X" case imageExtension = "IMAGE_EXT"
case width = "WIDTH" case width = "WIDTH"
case height = "HEIGHT" case height = "HEIGHT"
case leftText = "LEFT_TEXT" case leftText = "LEFT_TEXT"

View File

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

View File

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

View File

@ -1,10 +1,8 @@
import Foundation import Foundation
import ArgumentParser import ArgumentParser
var configuration: Configuration!
let log = ValidationLog() let log = ValidationLog()
var files: FileSystem! var files: FileSystem!
var siteRoot: Element!
@main @main
struct CHGenerator: ParsableCommand { 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 configUrl = URL(fileURLWithPath: configPath)
let data = try Data(contentsOf: configUrl) let config: Configuration
configuration = try JSONDecoder().decode(from: data) 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( files = .init(
in: configuration.contentDirectory, 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 return
} }
let siteGenerator = try SiteGenerator() let siteGenerator = try SiteGenerator()

View File

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