Compare commits
No commits in common. "6e24c27fdc69702ac1d19dbd036f37554eb94e3a" and "c82080db82e9f3f6a72f4ad9b8209f9c12b0bacc" have entirely different histories.
6e24c27fdc
...
c82080db82
@ -1,6 +0,0 @@
|
|||||||
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,52 +145,77 @@ extension Element {
|
|||||||
|
|
||||||
extension Element.LocalizedMetadata {
|
extension Element.LocalizedMetadata {
|
||||||
|
|
||||||
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata, log: MetadataInfoLogger) {
|
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata) {
|
||||||
// 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 isValid = true
|
var isComplete = true
|
||||||
|
func markAsIncomplete() {
|
||||||
|
isComplete = false
|
||||||
|
}
|
||||||
let source = "root"
|
let source = "root"
|
||||||
self.language = log.required(data.language, name: "language", source: source, &isValid)
|
self.language = log
|
||||||
self.title = log.required(data.title, name: "title", source: source, &isValid)
|
.required(data.language, name: "language", source: source)
|
||||||
|
.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 = data.linkPreviewImage
|
self.linkPreviewImage = log
|
||||||
|
.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.required(linkPreviewDescription, name: "linkPreviewDescription", source: source, &isValid)
|
self.linkPreviewDescription = log
|
||||||
self.moreLinkText = data.moreLinkText
|
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
||||||
self.backLinkText = log.required(data.backLinkText, name: "backLinkText", source: source, &isValid)
|
.ifNil(markAsIncomplete) ?? ""
|
||||||
|
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.required(data.placeholderTitle, name: "placeholderTitle", source: source, &isValid)
|
self.placeholderTitle = log
|
||||||
self.placeholderText = log.required(data.placeholderText, name: "placeholderText", source: source, &isValid)
|
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
|
||||||
|
.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.unused(data.externalUrl, "externalUrl", source: source)
|
self.externalUrl = log.unexpected(data.externalUrl, name: "externalUrl", source: source)
|
||||||
self.relatedContentText = log.required(data.relatedContentText, name: "relatedContentText", source: source, &isValid)
|
self.relatedContentText = log
|
||||||
self.navigationTextAsNextPage = log.required(data.navigationTextAsNextPage, name: "navigationTextAsNextPage", source: source, &isValid)
|
.required(data.relatedContentText, name: "relatedContentText", source: source) ?? ""
|
||||||
self.navigationTextAsPreviousPage = log.required(data.navigationTextAsPreviousPage, name: "navigationTextAsPreviousPage", source: source, &isValid)
|
self.navigationTextAsNextPage = log
|
||||||
|
.required(data.navigationTextAsNextPage, name: "navigationTextAsNextPage", source: source) ?? ""
|
||||||
|
self.navigationTextAsPreviousPage = log
|
||||||
|
.required(data.navigationTextAsPreviousPage, name: "navigationTextAsPreviousPage", source: source) ?? ""
|
||||||
|
|
||||||
guard isValid else {
|
guard isComplete else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata, log: MetadataInfoLogger) {
|
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata) {
|
||||||
// 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 isValid = true
|
var isComplete = true
|
||||||
|
func markAsIncomplete() {
|
||||||
|
isComplete = false
|
||||||
|
}
|
||||||
self.language = parent.language
|
self.language = parent.language
|
||||||
self.title = log.required(data.title, name: "title", source: source, &isValid)
|
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 = data.linkPreviewImage
|
self.linkPreviewImage = log
|
||||||
|
.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.required(linkPreviewDescription, name: "linkPreviewDescription", source: source, &isValid)
|
self.linkPreviewDescription = log
|
||||||
self.moreLinkText = log.required(data.moreLinkText ?? parent.moreLinkText, name: "moreLinkText", source: source, &isValid)
|
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
||||||
|
.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
|
||||||
@ -203,7 +228,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 isValid else {
|
guard isComplete else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -126,12 +126,6 @@ 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.
|
||||||
*/
|
*/
|
||||||
@ -163,130 +157,122 @@ 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, log: MetadataInfoLogger) {
|
init?(atRoot folder: URL) {
|
||||||
self.inputFolder = folder
|
self.inputFolder = folder
|
||||||
self.path = ""
|
self.path = ""
|
||||||
|
|
||||||
let source = GenericMetadata.metadataFileName
|
let source = GenericMetadata.metadataFileName
|
||||||
guard let metadata = GenericMetadata(source: source, log: log) else {
|
guard let metadata = GenericMetadata(source: source) 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, &isValid)
|
self.author = log.required(metadata.author, name: "author", source: source) ?? "author"
|
||||||
self.topBarTitle = log.required(metadata.topBarTitle, name: "topBarTitle", source: source, &isValid)
|
self.topBarTitle = log
|
||||||
self.date = log.castUnused(metadata.date, "date", source: source)
|
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
|
||||||
self.endDate = log.castUnused(metadata.endDate, "endDate", source: source)
|
self.date = log.unused(metadata.date, "date", source: source)
|
||||||
self.state = log.cast(metadata.state, "state", source: source)
|
self.endDate = log.unused(metadata.endDate, "endDate", 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.castUnused(metadata.thumbnailStyle, "thumbnailStyle", source: source)
|
self.thumbnailStyle = log.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large
|
||||||
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source)
|
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true
|
||||||
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
|
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
|
||||||
self.headerType = log.cast(metadata.headerType, "headerType", source: source)
|
self.headerType = log.headerType(metadata.headerType, source: source)
|
||||||
self.showMostRecentSection = metadata.showMostRecentSection ?? false
|
self.languages = log.required(metadata.languages, name: "languages", source: source)?
|
||||||
self.languages = log.required(metadata.languages, name: "languages", source: source, &isValid)
|
|
||||||
.compactMap { language in
|
.compactMap { language in
|
||||||
.init(atRoot: folder, data: language, log: log)
|
.init(atRoot: folder, data: language)
|
||||||
}
|
} ?? []
|
||||||
// All properties initialized
|
// All properties initialized
|
||||||
guard !languages.isEmpty else {
|
guard !languages.isEmpty else {
|
||||||
log.error("No languages found", source: source)
|
log.add(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, log: log)
|
self.readElements(in: folder, source: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
mutating func readElements(in folder: URL, source: String?, log: MetadataInfoLogger) {
|
mutating func readElements(in folder: URL, source: String?) {
|
||||||
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.error("Failed to read subfolders: \(error)", source: source ?? "root")
|
log.add(error: "Failed to read subfolders", source: source ?? "root", error: error)
|
||||||
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, log: log)
|
return Element(parent: self, folder: subFolder, path: s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(parent: Element, folder: URL, path: String, log: MetadataInfoLogger) {
|
init?(parent: Element, folder: URL, path: String) {
|
||||||
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, log: log) else {
|
guard let metadata = GenericMetadata(source: source) 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.unused(metadata.topBarTitle, "topBarTitle", source: source)
|
self.topBarTitle = log
|
||||||
self.date = metadata.date.unwrapped { log.cast($0, "date", source: source) }
|
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
|
||||||
self.endDate = metadata.date.unwrapped { log.cast($0, "endDate", source: source) }
|
let date = log.date(from: metadata.date, property: "date", source: source).ifNil {
|
||||||
self.state = log.cast(metadata.state, "state", source: source)
|
if !parent.useManualSorting {
|
||||||
self.sortIndex = metadata.sortIndex
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
// 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.cast(metadata.thumbnailStyle, "thumbnailStyle", source: source)
|
self.thumbnailStyle = log.thumbnailStyle(metadata.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.cast(metadata.headerType, "headerType", source: source)
|
self.headerType = log.headerType(metadata.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.warning("Language '\(parentData.language)' not found", source: source)
|
log.add(info: "Language '\(parentData.language)' not found", source: source)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return .init(folder: folder, data: data, source: source, parent: parentData, log: log)
|
return .init(folder: folder, data: data, source: source, parent: parentData)
|
||||||
}
|
}
|
||||||
// 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, &isValid)
|
log.required(metadata.languages, name: "languages", source: source)?
|
||||||
.compactMap { log.required($0.language, name: "language", source: source, &isValid) }
|
.compactMap { log.required($0.language, name: "language", source: source) }
|
||||||
.filter { language in
|
.filter { language in
|
||||||
!parent.languages.contains { $0.language == language }
|
!parent.languages.contains { $0.language == language }
|
||||||
}
|
}
|
||||||
.forEach {
|
.forEach {
|
||||||
log.warning("Language '\($0)' not found in parent, so not generated", source: source)
|
log.add(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, log: log)
|
self.readElements(in: folder, source: path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -324,17 +310,6 @@ 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! }
|
||||||
@ -382,7 +357,7 @@ extension Element {
|
|||||||
// The relative path needs to go down to the first common folder,
|
// The relative path needs to go down to the first common folder,
|
||||||
// before going up to the target page
|
// before going up to the target page
|
||||||
let allParts = [String](repeating: "..", count: ownParts.count-index)
|
let allParts = [String](repeating: "..", count: ownParts.count-index)
|
||||||
+ pageParts.dropFirst(index)
|
+ pageParts.dropFirst(index)
|
||||||
return allParts.joined(separator: "/")
|
return allParts.joined(separator: "/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,8 +14,8 @@ extension GenericMetadata {
|
|||||||
let language: String?
|
let language: String?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
The title used in the page header.
|
|
||||||
- Note: This field is mandatory
|
- Note: This field is mandatory
|
||||||
|
The title used in the page header.
|
||||||
*/
|
*/
|
||||||
let title: String?
|
let title: String?
|
||||||
|
|
||||||
|
@ -125,12 +125,6 @@ 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.
|
||||||
*/
|
*/
|
||||||
@ -156,7 +150,6 @@ extension GenericMetadata: Codable {
|
|||||||
.useManualSorting,
|
.useManualSorting,
|
||||||
.overviewItemCount,
|
.overviewItemCount,
|
||||||
.headerType,
|
.headerType,
|
||||||
.showMostRecentSection,
|
|
||||||
.languages,
|
.languages,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -176,8 +169,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, log: MetadataInfoLogger) {
|
init?(source: String) {
|
||||||
guard let data = log.readPotentialMetadata(atPath: source, source: source) else {
|
guard let data = files.dataOfOptionalFile(atPath: source, source: source) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,7 +196,8 @@ extension GenericMetadata {
|
|||||||
do {
|
do {
|
||||||
self = try decoder.decode(from: data)
|
self = try decoder.decode(from: data)
|
||||||
} catch {
|
} catch {
|
||||||
log.failedToDecodeMetadata(source: source, error: error)
|
print("Here \(data)")
|
||||||
|
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -228,7 +222,6 @@ extension GenericMetadata {
|
|||||||
useManualSorting: false,
|
useManualSorting: false,
|
||||||
overviewItemCount: 6,
|
overviewItemCount: 6,
|
||||||
headerType: "left",
|
headerType: "left",
|
||||||
showMostRecentSection: false,
|
|
||||||
languages: [.full])
|
languages: [.full])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,19 +17,3 @@ 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,19 +39,3 @@ 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 }
|
|
||||||
}
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
protocol StringProperty {
|
|
||||||
|
|
||||||
init?(_ value: String)
|
|
||||||
|
|
||||||
static var castFailureReason: String { get }
|
|
||||||
}
|
|
@ -33,19 +33,3 @@ 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 }
|
|
||||||
}
|
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Array: DefaultValueProvider {
|
|
||||||
|
|
||||||
static var defaultValue: Array<Element> { [] }
|
|
||||||
}
|
|
@ -1,6 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Bool: DefaultValueProvider {
|
|
||||||
|
|
||||||
static var defaultValue: Bool { true }
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
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() }
|
|
||||||
}
|
|
@ -1,13 +0,0 @@
|
|||||||
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,11 +20,6 @@ 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
|
||||||
@ -79,8 +74,3 @@ extension String {
|
|||||||
try data(using: .utf8)!.createFolderAndWrite(to: url)
|
try data(using: .utf8)!.createFolderAndWrite(to: url)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String: DefaultValueProvider {
|
|
||||||
|
|
||||||
static var defaultValue: String { "" }
|
|
||||||
}
|
|
||||||
|
@ -56,13 +56,4 @@ 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,14 +14,10 @@ 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
|
||||||
*/
|
*/
|
||||||
@ -70,12 +66,11 @@ final class FileSystem {
|
|||||||
*/
|
*/
|
||||||
private var generatedPages: Set<String> = []
|
private var generatedPages: Set<String> = []
|
||||||
|
|
||||||
init(in input: URL, to output: URL, configuration: Configuration) {
|
init(in input: URL, to output: URL) {
|
||||||
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 {
|
||||||
@ -105,8 +100,18 @@ final class FileSystem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func contentOfMdFile(atPath path: String, source: String) -> String? {
|
func dataOfOptionalFile(atPath path: String, source: String) -> Data? {
|
||||||
contentOfOptionalFile(atPath: path, source: source, createEmptyFileIfMissing: configuration.createMdFilesIfMissing)
|
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 contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
|
func contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
|
||||||
@ -133,30 +138,8 @@ final class FileSystem {
|
|||||||
// MARK: Images
|
// MARK: Images
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func requireSingleImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
|
func requireImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
|
||||||
images.requireImage(
|
images.requireImage(at: destination, generatedFrom: source, requiredBy: path, width: width, height: desiredHeight)
|
||||||
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() {
|
||||||
@ -269,7 +252,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 FileSystem.safeShell(command)
|
_ = try 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)
|
||||||
@ -280,7 +263,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 FileSystem.safeShell(command)
|
_ = try 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)
|
||||||
@ -423,7 +406,7 @@ final class FileSystem {
|
|||||||
// MARK: Running other tasks
|
// MARK: Running other tasks
|
||||||
|
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func safeShell(_ command: String) throws -> String {
|
func safeShell(_ command: String) throws -> String {
|
||||||
let task = Process()
|
let task = Process()
|
||||||
let pipe = Pipe()
|
let pipe = Pipe()
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AppKit
|
import AppKit
|
||||||
import CryptoKit
|
import CryptoKit
|
||||||
import Darwin.C
|
|
||||||
|
|
||||||
private struct ImageJob {
|
private struct ImageJob {
|
||||||
|
|
||||||
@ -10,18 +9,10 @@ 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.
|
||||||
*/
|
*/
|
||||||
@ -39,13 +30,6 @@ 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.
|
||||||
|
|
||||||
@ -70,11 +54,6 @@ 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.
|
||||||
|
|
||||||
@ -133,7 +112,7 @@ final class ImageGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireImage(at destination: String, generatedFrom source: String, requiredBy path: String, quality: Float, width: Int, height: Int?, alwaysGenerate: Bool) -> NSSize {
|
func requireImage(at destination: String, generatedFrom source: String, requiredBy path: String, width: Int, height: Int?) -> 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)
|
||||||
@ -156,44 +135,28 @@ final class ImageGenerator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let job = ImageJob(
|
let job = ImageJob(destination: destination, width: width, path: path)
|
||||||
destination: destination,
|
|
||||||
width: width,
|
|
||||||
path: path,
|
|
||||||
quality: quality,
|
|
||||||
alwaysGenerate: alwaysGenerate)
|
|
||||||
insert(job: job, source: source)
|
|
||||||
|
|
||||||
|
guard let existingSource = imageJobs[source] else {
|
||||||
|
imageJobs[source] = [job]
|
||||||
|
return scaledSize
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let existingJob = existingSource.first(where: { $0.destination == destination}) else {
|
||||||
|
imageJobs[source] = existingSource + [job]
|
||||||
|
return scaledSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if existingJob.width != width {
|
||||||
|
addWarning("Multiple image widths (\(existingJob.width) and \(width))", destination: destination, path: "\(existingJob.path) and \(path)")
|
||||||
|
}
|
||||||
return scaledSize
|
return scaledSize
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
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()
|
||||||
@ -205,10 +168,7 @@ final class ImageGenerator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
print("\(missingImages.count) missing images:")
|
print("\(missingImages.count) missing images:")
|
||||||
let sort = missingImages.sorted { (a, b) in
|
for (source, path) in missingImages {
|
||||||
a.value < b.value && a.key < b.key
|
|
||||||
}
|
|
||||||
for (source, path) in sort {
|
|
||||||
print(" \(source) (required by \(path))")
|
print(" \(source) (required by \(path))")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -247,7 +207,7 @@ final class ImageGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func isMissing(_ job: ImageJob) -> Bool {
|
private func isMissing(_ job: ImageJob) -> Bool {
|
||||||
job.alwaysGenerate || !output.appendingPathComponent(job.destination).exists
|
!output.appendingPathComponent(job.destination).exists
|
||||||
}
|
}
|
||||||
|
|
||||||
private func create(images: [ImageJob], from source: String) {
|
private func create(images: [ImageJob], from source: String) {
|
||||||
@ -273,10 +233,6 @@ 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()
|
||||||
@ -316,7 +272,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(value: job.quality)]) else {
|
guard let data = rep.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
|
||||||
addWarning("Failed to get data", job: job)
|
addWarning("Failed to get data", job: job)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -328,129 +284,4 @@ 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,8 +4,6 @@ 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 {
|
||||||
@ -13,10 +11,6 @@ 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
|
||||||
}
|
}
|
||||||
@ -26,7 +20,7 @@ enum ImageType: CaseIterable {
|
|||||||
switch self {
|
switch self {
|
||||||
case .jpg:
|
case .jpg:
|
||||||
return .jpeg
|
return .jpeg
|
||||||
case .png, .avif, .webp:
|
case .png:
|
||||||
return .png
|
return .png
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,119 @@ 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
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
@ -8,11 +8,8 @@ struct PageContentGenerator {
|
|||||||
|
|
||||||
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
|
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
|
||||||
|
|
||||||
private let siteRoot: Element
|
init(factory: TemplateFactory) {
|
||||||
|
|
||||||
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) {
|
||||||
@ -123,14 +120,23 @@ 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.requireFullSizeMultiVersionImage(
|
let size = files.requireImage(
|
||||||
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.dropAfterLast("."),
|
.image: file,
|
||||||
.imageExtension: file.lastComponentAfter("."),
|
.image2x: file2x,
|
||||||
.width: "\(Int(size.width))",
|
.width: "\(Int(size.width))",
|
||||||
.height: "\(Int(size.height))",
|
.height: "\(Int(size.height))",
|
||||||
.leftText: leftTitle ?? "",
|
.leftText: leftTitle ?? "",
|
||||||
@ -255,6 +261,7 @@ 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)
|
||||||
|
|
||||||
@ -268,12 +275,13 @@ struct PageContentGenerator {
|
|||||||
content[.url] = "href=\"\(relativePageUrl)\""
|
content[.url] = "href=\"\(relativePageUrl)\""
|
||||||
}
|
}
|
||||||
|
|
||||||
content[.image] = relativeImageUrl.dropAfterLast(".")
|
content[.image] = relativeImageUrl
|
||||||
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)
|
@ -15,21 +15,6 @@ 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)
|
||||||
|
@ -5,11 +5,8 @@ struct PageGenerator {
|
|||||||
|
|
||||||
private let factory: LocalizedSiteTemplate
|
private let factory: LocalizedSiteTemplate
|
||||||
|
|
||||||
private let contentGenerator: PageContentGenerator
|
init(factory: LocalizedSiteTemplate) {
|
||||||
|
|
||||||
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?) {
|
||||||
@ -76,11 +73,15 @@ 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) {
|
||||||
if let raw = files.contentOfMdFile(atPath: path, source: page.path)?.trimmed.nonEmpty {
|
let create = configuration.createMdFilesIfMissing
|
||||||
let (content, includesCode) = contentGenerator.generate(page: page, language: language, content: raw)
|
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)
|
||||||
return (content, includesCode, false)
|
return (content, includesCode, false)
|
||||||
} else {
|
} else {
|
||||||
let (content, includesCode) = contentGenerator.generate(page: page, language: language, content: metadata.placeholderText)
|
let (content, includesCode) = PageContentGenerator(factory: factory.factory)
|
||||||
|
.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.requireSingleImage(
|
files.requireImage(
|
||||||
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, siteRoot: site)
|
let pageGenerator = PageGenerator(factory: template)
|
||||||
|
|
||||||
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.requireSingleImage(
|
files.requireImage(
|
||||||
source: $0.sourcePath,
|
source: $0.sourcePath,
|
||||||
destination: $0.destinationPath,
|
destination: $0.destinationPath,
|
||||||
requiredBy: element.path,
|
requiredBy: element.path,
|
||||||
|
@ -14,7 +14,8 @@ 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]()
|
||||||
|
|
||||||
@ -24,26 +25,32 @@ struct ThumbnailListGenerator {
|
|||||||
content[.url] = "href=\"\(relativePageUrl)\""
|
content[.url] = "href=\"\(relativePageUrl)\""
|
||||||
}
|
}
|
||||||
|
|
||||||
let (thumbnailSourcePath, thumbnailDestPath) = item.thumbnailFilePath(for: language)
|
content[.image] = relativeImageUrl
|
||||||
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.requireMultiVersionImage(
|
files.requireImage(
|
||||||
source: thumbnailSourcePath,
|
source: thumbnailSourcePath,
|
||||||
destination: thumbnailDestNoExtension + ".jpg",
|
destination: thumbnailDestPath,
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,178 +0,0 @@
|
|||||||
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 imageExtension = "IMAGE_EXT"
|
case image2x = "IMAGE_2X"
|
||||||
case width = "WIDTH"
|
case width = "WIDTH"
|
||||||
case height = "HEIGHT"
|
case height = "HEIGHT"
|
||||||
case leftText = "LEFT_TEXT"
|
case leftText = "LEFT_TEXT"
|
||||||
|
@ -5,6 +5,7 @@ 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,6 +8,7 @@ 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,8 +1,10 @@
|
|||||||
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 {
|
||||||
@ -15,48 +17,17 @@ struct CHGenerator: ParsableCommand {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 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 {
|
private func generate(configPath: String) throws {
|
||||||
guard let configuration = loadConfiguration(at: configPath) else {
|
let configUrl = URL(fileURLWithPath: configPath)
|
||||||
return
|
let data = try Data(contentsOf: configUrl)
|
||||||
}
|
configuration = try JSONDecoder().decode(from: data)
|
||||||
|
|
||||||
files = .init(
|
files = .init(
|
||||||
in: configuration.contentDirectory,
|
in: configuration.contentDirectory,
|
||||||
to: configuration.outputDirectory,
|
to: configuration.outputDirectory)
|
||||||
configuration: configuration)
|
|
||||||
|
|
||||||
|
siteRoot = Element(atRoot: configuration.contentDirectory)
|
||||||
// 2. Scan site elements
|
guard siteRoot != nil else {
|
||||||
guard let siteRoot = try loadSiteData(in: configuration.contentDirectory) else {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let siteGenerator = try SiteGenerator()
|
let siteGenerator = try SiteGenerator()
|
||||||
|
@ -11,6 +11,3 @@ 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