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
|
- 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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! }
|
||||||
|
@ -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?
|
||||||
|
|
||||||
|
@ -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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
|
}
|
||||||
|
@ -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: 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 {
|
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)
|
||||||
}
|
}
|
||||||
|
@ -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 { "" }
|
||||||
|
}
|
||||||
|
@ -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)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
return scaledSize
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insert(job: ImageJob, source: String) {
|
||||||
guard let existingSource = imageJobs[source] else {
|
guard let existingSource = imageJobs[source] else {
|
||||||
imageJobs[source] = [job]
|
imageJobs[source] = [job]
|
||||||
return scaledSize
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let existingJob = existingSource.first(where: { $0.destination == destination}) else {
|
guard let existingJob = existingSource.first(where: { $0.destination == job.destination }) else {
|
||||||
imageJobs[source] = existingSource + [job]
|
imageJobs[source] = existingSource + [job]
|
||||||
return scaledSize
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if existingJob.width != width {
|
if existingJob.width != job.width {
|
||||||
addWarning("Multiple image widths (\(existingJob.width) and \(width))", destination: destination, path: "\(existingJob.path) and \(path)")
|
addWarning("Multiple image widths (\(existingJob.width) and \(job.width))", destination: job.destination, path: "\(existingJob.path) and \(job.path)")
|
||||||
}
|
}
|
||||||
return scaledSize
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createImages() {
|
func createImages() {
|
||||||
|
var count = 0
|
||||||
for (source, jobs) in imageJobs.sorted(by: { $0.key < $1.key }) {
|
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)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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)
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {
|
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"
|
||||||
|
@ -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"
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
|
@ -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 config: Configuration
|
||||||
|
do {
|
||||||
let data = try Data(contentsOf: configUrl)
|
let data = try Data(contentsOf: configUrl)
|
||||||
configuration = try JSONDecoder().decode(from: data)
|
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()
|
||||||
|
@ -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
|
||||||
|
Loading…
Reference in New Issue
Block a user