Compare commits
10 Commits
c82080db82
...
6e24c27fdc
Author | SHA1 | Date | |
---|---|---|---|
|
6e24c27fdc | ||
|
92d832dc44 | ||
|
90d2573d0c | ||
|
94375f3a81 | ||
|
58eae51d40 | ||
|
27b8d5b3ee | ||
|
1ceba25d4f | ||
|
4c2c4b7dd3 | ||
|
58f7642ca5 | ||
|
112bbe252c |
6
Sources/Generator/Content/DefaultValueProvider.swift
Normal file
6
Sources/Generator/Content/DefaultValueProvider.swift
Normal file
@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
protocol DefaultValueProvider {
|
||||
|
||||
static var defaultValue: Self { get }
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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: "/")
|
||||
}
|
||||
|
||||
|
@ -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?
|
||||
|
||||
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
|
8
Sources/Generator/Content/StringProperty.swift
Normal file
8
Sources/Generator/Content/StringProperty.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
protocol StringProperty {
|
||||
|
||||
init?(_ value: String)
|
||||
|
||||
static var castFailureReason: String { get }
|
||||
}
|
@ -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 }
|
||||
}
|
||||
|
6
Sources/Generator/Extensions/Array+Extensions.swift
Normal file
6
Sources/Generator/Extensions/Array+Extensions.swift
Normal file
@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
extension Array: DefaultValueProvider {
|
||||
|
||||
static var defaultValue: Array<Element> { [] }
|
||||
}
|
6
Sources/Generator/Extensions/Bool+Extensions.swift
Normal file
6
Sources/Generator/Extensions/Bool+Extensions.swift
Normal file
@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
extension Bool: DefaultValueProvider {
|
||||
|
||||
static var defaultValue: Bool { true }
|
||||
}
|
26
Sources/Generator/Extensions/Date+Extensions.swift
Normal file
26
Sources/Generator/Extensions/Date+Extensions.swift
Normal 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() }
|
||||
}
|
13
Sources/Generator/Extensions/Int+Extensions.swift
Normal file
13
Sources/Generator/Extensions/Int+Extensions.swift
Normal 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 }
|
||||
}
|
@ -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)
|
||||
}
|
||||
|
@ -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 { "" }
|
||||
}
|
||||
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}()
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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)
|
@ -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)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
178
Sources/Generator/Processing/MetadataInfoLogger.swift
Normal file
178
Sources/Generator/Processing/MetadataInfoLogger.swift
Normal 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)
|
||||
}
|
||||
}
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user