Compare commits

..

60 Commits

Author SHA1 Message Date
aa97a738b4 Fix external links in related content 2024-03-08 17:09:29 +01:00
ddf489c2c4 Fix script imports 2023-12-18 21:30:39 +01:00
54a4d6dbc3 Detect duplicate ids 2023-12-16 22:10:50 +01:00
294319a205 Add 3d viewer command, improve code 2023-12-16 22:10:27 +01:00
1d97560c40 Add 3d model short command 2023-12-16 10:55:36 +01:00
81fa5c38de Improve warning 2023-12-08 14:49:59 +01:00
6365e963c5 Fix comment 2023-09-27 22:48:54 +02:00
d1f7738f09 Add ignored state for pages 2023-08-16 12:11:59 +02:00
5cb0ef6b87 Prevent linking to unpublished content 2023-08-03 13:29:11 +02:00
2608e870cc Allow page id references with section 2023-07-28 13:59:08 +02:00
bd57c6fbf5 Fix date format below headlines 2023-07-28 13:21:57 +02:00
4cb72677db Fix video paths for external files 2023-05-31 23:08:55 +02:00
36b2842ee9 Add all video versions for video input 2023-05-31 22:36:23 +02:00
884d456e48 Ignore issues 2023-03-13 10:36:26 +01:00
05d2c48b57 Fix overview of nested pages 2023-02-28 21:13:36 +01:00
86440af01f Add html ids to headlines 2023-02-28 09:31:44 +01:00
a2ed35a26d Generate content for pages with no visible children 2023-02-22 14:46:40 +01:00
1b6441e03e Fix crash for relative links 2023-02-22 11:47:26 +01:00
5f5c250272 Add special character encoding to external links 2023-02-22 11:46:50 +01:00
6e717a8cf7 Decode percent encodings for markdown images 2023-02-20 15:40:31 +01:00
87d54788db Allow brackets in download buttons 2023-02-20 15:34:26 +01:00
89245f2553 Add dependency install info to overview 2023-02-01 20:27:11 +01:00
6b32f37fd9 Add missing dependencies to install script 2023-02-01 20:26:56 +01:00
a05ed4dfcd Check if log file exists 2023-01-08 21:50:28 +01:00
e5804ac0c7 Actually write files log 2023-01-08 21:49:01 +01:00
10267e3765 Only show standard pages in most recent 2023-01-08 21:14:00 +01:00
4ccb67d7ef Remove log files if empty 2023-01-08 21:13:40 +01:00
67a0da13bd Improve SVG layout 2022-12-27 09:57:34 +01:00
5ecfc0d51d Add alt text to images 2022-12-21 12:59:42 +01:00
7a0e1300ac Add language to HTML tags 2022-12-20 12:49:21 +01:00
72e0db7f6f Fix image specification bug 2022-12-20 00:33:25 +01:00
0eee845431 Support Gif in pages 2022-12-19 23:31:06 +01:00
260de52533 Change run time display 2022-12-17 08:26:35 +01:00
7c68d02169 Only show large thumbnails in most recent 2022-12-17 08:26:12 +01:00
3d361845ab Fix image logging bug 2022-12-14 09:51:46 +01:00
dbb088fa82 Fix content path processing 2022-12-10 22:40:35 +01:00
31923974a6 Split minify options for CSS and JS 2022-12-10 22:28:39 +01:00
3991211e37 Allow relative paths in configuration file 2022-12-09 12:09:57 +01:00
a563c56ec2 Print full screen setting 2022-12-08 18:21:09 +01:00
59667af4b0 Generate full-screen images 2022-12-08 17:16:54 +01:00
3bd75a63ab Finish most recent and featured section 2022-12-07 01:01:13 +01:00
f185191b7f Check more dependencies, fix bug 2022-12-05 17:49:15 +01:00
deb7e6187e Fix links to external pages 2022-12-05 17:25:07 +01:00
464ece4a03 Improve summary print 2022-12-05 11:51:19 +01:00
225c68ecd1 Fix display of total optimization count 2022-12-05 11:51:04 +01:00
f52c3bc8b9 Check dependencies and outputs 2022-12-05 11:43:30 +01:00
a8b328efce Write all logs to disk 2022-12-04 23:10:44 +01:00
956cfb52c4 Improve printing and image creation 2022-12-04 19:15:22 +01:00
6a52f62402 Add result handler to templates 2022-12-02 10:25:54 +01:00
6e24c27fdc Determine elements for news section 2022-12-01 15:39:39 +01:00
92d832dc44 Remove global site root 2022-12-01 15:25:55 +01:00
90d2573d0c Add imageOptim to install script 2022-12-01 15:19:41 +01:00
94375f3a81 Remove global configuration and improve printing 2022-12-01 15:19:17 +01:00
58eae51d40 Shorten metadata logging 2022-12-01 15:03:29 +01:00
27b8d5b3ee Add warnings and errors to output 2022-12-01 14:52:36 +01:00
1ceba25d4f Improve logging during element scanning 2022-12-01 14:50:26 +01:00
4c2c4b7dd3 Optimize images with ImageOptim 2022-11-30 15:29:51 +01:00
58f7642ca5 Fix links and images in related content 2022-11-27 22:06:18 +01:00
112bbe252c Generate avif and webp image versions 2022-11-27 20:31:56 +01:00
c82080db82 Allow custom thumbnail paths in metadata 2022-09-29 21:23:41 +02:00
66 changed files with 3108 additions and 1488 deletions

1
.gitignore vendored
View File

@ -2,3 +2,4 @@ config.json
.build .build
.swiftpm .swiftpm
Package.resolved Package.resolved
Issues.md

View File

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

View File

@ -57,7 +57,7 @@ extension Element {
- Note: If this value is inherited from the parent, if it is not defined. There must be at least one - Note: If this value is inherited from the parent, if it is not defined. There must be at least one
element in the path that defines this property. element in the path that defines this property.
*/ */
let moreLinkText: String let moreLinkText: String?
/** /**
The text on the back navigation link of **contained** elements. The text on the back navigation link of **contained** elements.
@ -140,82 +140,71 @@ extension Element {
This property is mandatory at root level, and is propagated to child elements. This property is mandatory at root level, and is propagated to child elements.
*/ */
let navigationTextAsPreviousPage: String let navigationTextAsPreviousPage: String
/**
The text to display above a slideshow for most recent items.
Only used for elements that define `showMostRecentSection = true`
*/
let mostRecentTitle: String?
/**
The text to display above a slideshow for featured items.
Only used for elements that define `showFeaturedSection = true`
*/
let featuredTitle: String?
} }
} }
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.mostRecentTitle = data.mostRecentTitle
self.navigationTextAsPreviousPage = log self.featuredTitle = data.featuredTitle
.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
@ -227,33 +216,11 @@ extension Element.LocalizedMetadata {
self.relatedContentText = data.relatedContentText ?? parent.relatedContentText self.relatedContentText = data.relatedContentText ?? parent.relatedContentText
self.navigationTextAsPreviousPage = data.navigationTextAsPreviousPage ?? parent.navigationTextAsPreviousPage self.navigationTextAsPreviousPage = data.navigationTextAsPreviousPage ?? parent.navigationTextAsPreviousPage
self.navigationTextAsNextPage = data.navigationTextAsNextPage ?? parent.navigationTextAsNextPage self.navigationTextAsNextPage = data.navigationTextAsNextPage ?? parent.navigationTextAsNextPage
self.mostRecentTitle = data.mostRecentTitle ?? parent.mostRecentTitle
self.featuredTitle = data.featuredTitle ?? parent.featuredTitle
guard isComplete else { guard isValid else {
return nil return nil
} }
} }
} }
// MARK: Thumbnails
extension Element {
static let defaultThumbnailName = "thumbnail.jpg"
static func localizedThumbnailName(for language: String) -> String {
"thumbnail-\(language).jpg"
}
static func findThumbnail(for language: String, in folder: URL) -> String? {
let localizedThumbnail = localizedThumbnailName(for: language)
let localizedThumbnailUrl = folder.appendingPathComponent(localizedThumbnail)
if localizedThumbnailUrl.exists {
return localizedThumbnail
}
let defaultThumbnailUrl = folder.appendingPathComponent(defaultThumbnailName)
if defaultThumbnailUrl.exists {
return defaultThumbnailName
}
return nil
}
}

View File

@ -65,7 +65,7 @@ struct Element {
let sortIndex: Int? let sortIndex: Int?
/** /**
All files which may occur in content but is stored externally. All files which may occur in content but are stored externally.
Missing files which would otherwise produce a warning are ignored when included here. Missing files which would otherwise produce a warning are ignored when included here.
- Note: This property defaults to an empty set. - Note: This property defaults to an empty set.
@ -85,6 +85,15 @@ struct Element {
*/ */
let images: [ManualImage] let images: [ManualImage]
/**
The path to the thumbnail file.
This property is optional, and defaults to ``GenericMetadata.defaultThumbnailName``.
Note: The generator first looks for localized versions of the thumbnail by appending `-[lang]` to the file name,
e.g. `customThumb-en.jpg`. If no file is found, then the specified file is tried.
*/
let thumbnailPath: String
/** /**
The style of thumbnail to use when generating overviews. The style of thumbnail to use when generating overviews.
@ -117,6 +126,19 @@ 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
/**
Indicate that the overview section should contain a `Featured Content` section before the other sections.
The elements are the page ids of the elements contained in the feature.
- Note: If not specified, this property defaults to `false`
*/
let featuredPages: [String]
/** /**
The localized metadata for each language. The localized metadata for each language.
*/ */
@ -148,120 +170,183 @@ 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: "", log: log) } ?? []
self.thumbnailStyle = log.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true self.thumbnailStyle = log.castUnused(metadata.thumbnailStyle, "thumbnailStyle", source: source)
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source)
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault self.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.featuredPages = metadata.featuredPages ?? []
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 return nil
} }
files.add(page: path, id: id) guard isValid else {
self.readElements(in: folder, source: nil) return nil
} }
mutating func readElements(in folder: URL, source: String?) { //files.add(page: path, id: id)
self.readElements(in: folder, source: nil, log: log)
}
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
}
let state: PageState = log.cast(metadata.state, "state", source: source)
guard state != .ignored 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.endDate.unwrapped { log.cast($0, "endDate", source: source) }
if !parent.useManualSorting {
log.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source)
}
}
self.date = date
self.endDate = log.date(from: metadata.endDate, property: "endDate", source: source).ifNotNil {
if date == nil {
log.add(warning: "Set 'endDate', but no 'date'", source: source)
}
}
let state = log.state(metadata.state, source: source)
self.state = state self.state = state
self.sortIndex = metadata.sortIndex.ifNil { self.sortIndex = metadata.sortIndex
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, log: log) } ?? []
self.thumbnailStyle = log.thumbnailStyle(metadata.thumbnailStyle, source: source) self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
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.featuredPages = metadata.featuredPages ?? []
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
files.add(page: path, id: id) if self.date == nil, !parent.useManualSorting {
self.readElements(in: folder, source: path) 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
}
self.readElements(in: folder, source: path, log: log)
if showMostRecentSection {
if elements.isEmpty {
log.error("Page has no children", source: source)
}
languages.filter { $0.mostRecentTitle == nil }.forEach {
log.error("'showMostRecentSection' = true, but 'mostRecentTitle' not set for language '\($0.language)'", source: source)
}
}
if !featuredPages.isEmpty {
if elements.isEmpty {
log.error("'featuredPages' contains elements, but page has no children", source: source)
}
languages.filter { $0.featuredTitle == nil }.forEach {
log.error("'featuredPages' contains elements, but 'featuredTitle' not set for language '\($0.language)'", source: source)
}
}
}
func getExternalPageMap(language: String, log: MetadataInfoLogger) -> [String : String] {
var result = [String : String]()
if let ext = getExternalLink(for: language) {
result[id] = ext
} else {
result[id] = path + Element.htmlPagePathAddition(for: language)
}
elements.forEach { element in
element.getExternalPageMap(language: language, log: log).forEach { key, value in
if result[key] != nil {
log.error("Page id '\(key)' is used twice", source: value)
}
result[key] = value
}
}
return result
}
private func getExternalLink(for language: String) -> String? {
languages.first { $0.language == language }?.externalUrl
}
var needsFirstSection: Bool {
showMostRecentSection || !featuredPages.isEmpty
}
var hasVisibleChildren: Bool {
!elements.filter { $0.state == .standard }.isEmpty
} }
} }
@ -288,7 +373,7 @@ extension Element {
} }
var hasNestingElements: Bool { var hasNestingElements: Bool {
elements.contains { $0.containsElements } elements.contains { $0.hasVisibleChildren }
} }
func itemsForOverview(_ count: Int? = nil) -> [Element] { func itemsForOverview(_ count: Int? = nil) -> [Element] {
@ -299,6 +384,20 @@ extension Element {
} }
} }
func mostRecentElements(_ count: Int) -> [Element] {
guard self.thumbnailStyle == .large else {
return []
}
guard self.containsElements else {
return [self]
}
let all = shownItems
.reduce(into: [Element]()) { $0 += $1.mostRecentElements(count) }
.filter { $0.thumbnailStyle == .large && $0.state == .standard && $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! }
@ -333,6 +432,9 @@ extension Element {
- Returns: The relative url from a localized page of the element to the target file. - Returns: The relative url from a localized page of the element to the target file.
*/ */
func relativePathToOtherSiteElement(file: String) -> String { func relativePathToOtherSiteElement(file: String) -> String {
guard !file.hasPrefix("/"), !file.hasPrefix("https://"), !file.hasPrefix("http://") else {
return file
}
// Note: The element `path` is missing the last component // Note: The element `path` is missing the last component
// i.e. travel/alps instead of travel/alps/en.html // i.e. travel/alps instead of travel/alps/en.html
let ownParts = path.components(separatedBy: "/") let ownParts = path.components(separatedBy: "/")
@ -340,7 +442,7 @@ extension Element {
// Find the common elements of the path, which can be discarded // Find the common elements of the path, which can be discarded
var index = 0 var index = 0
while pageParts[index] == ownParts[index] { while index < pageParts.count && index < ownParts.count && pageParts[index] == ownParts[index] {
index += 1 index += 1
} }
// The relative path needs to go down to the first common folder, // The relative path needs to go down to the first common folder,
@ -420,17 +522,6 @@ extension Element {
extension Element { extension Element {
/**
Get the full path of the thumbnail image for the language (relative to the root folder).
*/
func thumbnailFilePath(for language: String) -> String {
guard let thumbnailFile = Element.findThumbnail(for: language, in: inputFolder) else {
log.add(error: "Missing thumbnail", source: path)
return Element.defaultThumbnailName
}
return pathRelativeToRootForContainedInputFile(thumbnailFile)
}
/** /**
The full url (relative to root) for the localized page The full url (relative to root) for the localized page
- Parameter language: The language of the page where the url should point - Parameter language: The language of the page where the url should point
@ -497,7 +588,7 @@ extension Element {
} }
func linkPreviewImage(for language: String) -> String? { func linkPreviewImage(for language: String) -> String? {
localized(for: language).linkPreviewImage localized(for: language).linkPreviewImage ?? thumbnailFileName(for: language)
} }
} }
@ -541,21 +632,14 @@ extension Element {
extension Element { extension Element {
private var additionalHeadContentPath: String { var additionalHeadContentPath: String {
path + "/head.html" path + "/head.html"
} }
func customHeadContent() -> String? { var additionalFooterContentPath: String {
files.contentOfOptionalFile(atPath: additionalHeadContentPath, source: path)
}
private var additionalFooterContentPath: String {
path + "/footer.html" path + "/footer.html"
} }
func customFooterContent() -> String? {
files.contentOfOptionalFile(atPath: additionalFooterContentPath, source: path)
}
} }
// MARK: Debug // MARK: Debug
@ -582,14 +666,14 @@ extension Element {
let desiredHeight: Int? let desiredHeight: Int?
init?(input: String, path: String) { init?(input: String, path: String, log: MetadataInfoLogger) {
let parts = input.components(separatedBy: " ").filter { !$0.isEmpty } let parts = input.components(separatedBy: " ").filter { !$0.isEmpty }
guard parts.count == 3 || parts.count == 4 else { guard parts.count == 3 || parts.count == 4 else {
log.add(error: "Invalid image specification, expected 'source dest width (height)", source: path) log.error("Invalid image specification, expected 'source dest width (height)", source: path)
return nil return nil
} }
guard let width = Int(parts[2]) else { guard let width = Int(parts[2]) else {
log.add(error: "Invalid width for image \(parts[0])", source: path) log.error("Invalid width for image \(parts[0])", source: path)
return nil return nil
} }
@ -601,7 +685,7 @@ extension Element {
return return
} }
guard let height = Int(parts[3]) else { guard let height = Int(parts[3]) else {
log.add(error: "Invalid height for image \(parts[0])", source: path) log.error("Invalid height for image \(parts[0])", source: path)
return nil return nil
} }
self.desiredHeight = height self.desiredHeight = height
@ -663,3 +747,53 @@ extension Element {
return node return node
} }
} }
// MARK: Thumbnails
extension Element {
static let defaultThumbnailName = "thumbnail.jpg"
/**
Find the thumbnail for the element.
This function uses either the custom thumbnail path from the metadata or the default name
to find a thumbnail. It first checks if a localized version of the thumbnail exists, or returns the
generic version. If no thumbnail image could be found on disk, then an error is logged and the
generic path is returned.
- Parameter language: The language of the thumbnail
- Returns: The thumbnail (either the localized or the generic version)
*/
func thumbnailFilePath(for language: String) -> (source: String, destination: String) {
let localizedThumbnail = thumbnailPath.insert("-\(language)", beforeLast: ".")
let localizedThumbnailUrl = inputFolder.appendingPathComponent(localizedThumbnail)
if localizedThumbnailUrl.exists {
let source = pathRelativeToRootForContainedInputFile(localizedThumbnail)
let ext = thumbnailPath.lastComponentAfter(".")
let destination = pathRelativeToRootForContainedInputFile("thumbnail-\(language).\(ext)")
return (source, destination)
}
let source = pathRelativeToRootForContainedInputFile(thumbnailPath)
let ext = thumbnailPath.lastComponentAfter(".")
let destination = pathRelativeToRootForContainedInputFile("thumbnail.\(ext)")
return (source, destination)
}
private func thumbnailFileName(for language: String) -> String? {
let localizedThumbnailName = thumbnailPath.insert("-\(language)", beforeLast: ".")
let localizedThumbnail = pathRelativeToRootForContainedInputFile(localizedThumbnailName)
let localizedThumbnailUrl = inputFolder.appendingPathComponent(localizedThumbnail)
if localizedThumbnailUrl.exists {
return localizedThumbnailName
}
let thumbnailUrl = inputFolder.appendingPathComponent(thumbnailPath)
if !thumbnailUrl.exists {
return nil
}
return thumbnailPath
}
}

View File

@ -14,8 +14,8 @@ extension GenericMetadata {
let language: String? let language: String?
/** /**
- Note: This field is mandatory
The title used in the page header. The title used in the page header.
- Note: This field is mandatory
*/ */
let title: String? let title: String?
@ -133,6 +133,18 @@ extension GenericMetadata {
This property is mandatory at root level, and is propagated to child elements. This property is mandatory at root level, and is propagated to child elements.
*/ */
let navigationTextAsNextPage: String? let navigationTextAsNextPage: String?
/**
The text to display above a slideshow for most recent items.
Only used for elements that define `showMostRecentSection = true`
*/
let mostRecentTitle: String?
/**
The text to display above a slideshow for featured items.
Only used for elements that define `showFeaturedSection = true`
*/
let featuredTitle: String?
} }
} }
@ -158,6 +170,8 @@ extension GenericMetadata.LocalizedMetadata: Codable {
.relatedContentText, .relatedContentText,
.navigationTextAsPreviousPage, .navigationTextAsPreviousPage,
.navigationTextAsNextPage, .navigationTextAsNextPage,
.mostRecentTitle,
.featuredTitle,
] ]
} }
@ -190,7 +204,9 @@ extension GenericMetadata.LocalizedMetadata {
externalUrl: nil, externalUrl: nil,
relatedContentText: nil, relatedContentText: nil,
navigationTextAsPreviousPage: nil, navigationTextAsPreviousPage: nil,
navigationTextAsNextPage: nil) navigationTextAsNextPage: nil,
mostRecentTitle: nil,
featuredTitle: nil)
} }
/** /**
@ -214,7 +230,9 @@ extension GenericMetadata.LocalizedMetadata {
externalUrl: nil, externalUrl: nil,
relatedContentText: "", relatedContentText: "",
navigationTextAsPreviousPage: "", navigationTextAsPreviousPage: "",
navigationTextAsNextPage: "") navigationTextAsNextPage: "",
mostRecentTitle: nil,
featuredTitle: nil)
} }
static var full: GenericMetadata.LocalizedMetadata { static var full: GenericMetadata.LocalizedMetadata {
@ -235,6 +253,8 @@ extension GenericMetadata.LocalizedMetadata {
externalUrl: "", externalUrl: "",
relatedContentText: "", relatedContentText: "",
navigationTextAsPreviousPage: "", navigationTextAsPreviousPage: "",
navigationTextAsNextPage: "") navigationTextAsNextPage: "",
mostRecentTitle: "",
featuredTitle: "")
} }
} }

View File

@ -84,6 +84,15 @@ struct GenericMetadata {
*/ */
let images: Set<String>? let images: Set<String>?
/**
The path to the thumbnail file.
This property is optional, and defaults to ``Element.defaultThumbnailName``.
Note: The generator first looks for localized versions of the thumbnail by appending `-[lang]` to the file name,
e.g. `customThumb-en.jpg`. If no file is found, then the specified file is tried.
*/
let thumbnailPath: String?
/** /**
The style of thumbnail to use when generating overviews. The style of thumbnail to use when generating overviews.
@ -116,6 +125,19 @@ 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?
/**
Indicate that the overview section should contain a `Featured Content` section before the other sections.
The elements are the page ids of the elements contained in the feature.
- Note: If not specified, this property defaults to `false`
*/
let featuredPages: [String]?
/** /**
The localized metadata for each language. The localized metadata for each language.
*/ */
@ -136,10 +158,13 @@ extension GenericMetadata: Codable {
.externalFiles, .externalFiles,
.requiredFiles, .requiredFiles,
.images, .images,
.thumbnailPath,
.thumbnailStyle, .thumbnailStyle,
.useManualSorting, .useManualSorting,
.overviewItemCount, .overviewItemCount,
.headerType, .headerType,
.showMostRecentSection,
.featuredPages,
.languages, .languages,
] ]
} }
@ -159,8 +184,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
} }
@ -186,8 +211,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
} }
} }
@ -207,10 +231,13 @@ extension GenericMetadata {
externalFiles: [], externalFiles: [],
requiredFiles: [], requiredFiles: [],
images: [], images: [],
thumbnailPath: "",
thumbnailStyle: "", thumbnailStyle: "",
useManualSorting: false, useManualSorting: false,
overviewItemCount: 6, overviewItemCount: 6,
headerType: "left", headerType: "left",
showMostRecentSection: false,
featuredPages: [],
languages: [.full]) languages: [.full])
} }
} }

View File

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

View File

@ -15,6 +15,11 @@ enum PageState: String {
Generate the page, but don't include it in overviews of the parent. Generate the page, but don't include it in overviews of the parent.
*/ */
case hidden case hidden
/**
Completely ignore the element.
*/
case ignored
} }
extension PageState { extension PageState {
@ -23,7 +28,7 @@ extension PageState {
switch self { switch self {
case .standard, .draft: case .standard, .draft:
return true return true
case .hidden: case .hidden, .ignored:
return false return false
} }
} }
@ -32,10 +37,24 @@ extension PageState {
switch self { switch self {
case .standard: case .standard:
return true return true
case .draft: case .draft, .hidden, .ignored:
return false
case .hidden:
return false return false
} }
} }
} }
extension PageState: StringProperty {
init?(_ value: String) {
self.init(rawValue: value)
}
static var castFailureReason: String {
"Page state must be 'standard', 'draft', 'hidden', or 'ignored'"
}
}
extension PageState: DefaultValueProvider {
static var defaultValue: PageState { .standard }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -20,6 +20,11 @@ extension String {
.joined(separator: "\n") .joined(separator: "\n")
} }
/**
Remove the part after the last occurence of the separator (including the separator itself).
The string is left unchanges, if it does not contain the separator.
*/
func dropAfterLast(_ separator: String) -> String { func dropAfterLast(_ separator: String) -> String {
guard contains(separator) else { guard contains(separator) else {
return self return self
@ -51,6 +56,11 @@ extension String {
return parts.dropLast().joined(separator: separator) + content + separator + parts.last! return parts.dropLast().joined(separator: separator) + content + separator + parts.last!
} }
/**
Remove everything behind the first separator.
Also removes the separator itself. If the separator is not contained in the string, then the full string is returned.
*/
func dropAfterFirst<T>(_ separator: T) -> String where T: StringProtocol { func dropAfterFirst<T>(_ separator: T) -> String where T: StringProtocol {
components(separatedBy: separator).first! components(separatedBy: separator).first!
} }
@ -62,10 +72,25 @@ extension String {
extension Substring { extension Substring {
func dropBeforeFirst(_ separator: String) -> String {
guard contains(separator) else {
return String(self)
}
return components(separatedBy: separator).dropFirst().joined(separator: separator)
}
func between(_ start: String, and end: String) -> String { func between(_ start: String, and end: String) -> String {
components(separatedBy: start).last! components(separatedBy: start).last!
.components(separatedBy: end).first! .components(separatedBy: end).first!
} }
func between(first: String, andLast last: String) -> String {
dropBeforeFirst(first).dropAfterLast(last)
}
func last(after: String) -> String {
components(separatedBy: after).last!
}
} }
extension String { extension String {
@ -74,3 +99,8 @@ extension String {
try data(using: .utf8)!.createFolderAndWrite(to: url) try data(using: .utf8)!.createFolderAndWrite(to: url)
} }
} }
extension String: DefaultValueProvider {
static var defaultValue: String { "" }
}

View File

@ -50,4 +50,23 @@ extension URL {
let attributes = try? FileManager.default.attributesOfItem(atPath: path) let attributes = try? FileManager.default.attributesOfItem(atPath: path)
return (attributes?[.size] as? NSNumber)?.intValue return (attributes?[.size] as? NSNumber)?.intValue
} }
func resolvingFolderTraversal() -> URL? {
var components = [String]()
absoluteString.components(separatedBy: "/").forEach { part in
if part == ".." {
if !components.isEmpty {
_ = components.popLast()
} else {
components.append("..")
}
return
}
if part == "." {
return
}
components.append(part)
}
return URL(string: components.joined(separator: "/"))
}
} }

View File

@ -16,22 +16,35 @@ struct Configuration: Codable {
let pageImageWidth: Int let pageImageWidth: Int
/** /**
Automatically minify all `.css` and `.js` resources which are copied The maximum width (in pixels) for images shown full screen.
*/
let fullScreenImageWidth: Int
/**
Automatically minify all `.css` resources which are copied
to the output folder. to the output folder.
- Note: This option requires the `uglifyjs` and `clean-css` tools, - Note: This option requires the `clean-css` tool,
which can be installed using the `install.sh` script in the root folder of the generator. which can be installed using the `install.sh` script in the root folder of the generator.
*/ */
let minifyCSSandJS: Bool let minifyCSS: Bool
/**
Automatically minify all `.js` resources which are copied
to the output folder.
- Note: This option requires the `uglifyjs` tool,
which can be installed using the `install.sh` script in the root folder of the generator.
*/
let minifyJavaScript: Bool
/** /**
The path to the directory where the root element metadata is located. The path to the directory where the root element metadata is located.
*/ */
let contentPath: String var contentPath: String
/** /**
The path where the generated website should be written. The path where the generated website should be written.
*/ */
let outputPath: String var outputPath: String
/** /**
Create .md files for content pages, if they don't exist. Create .md files for content pages, if they don't exist.
@ -56,4 +69,23 @@ struct Configuration: Codable {
var outputDirectory: URL { var outputDirectory: URL {
.init(fileURLWithPath: outputPath) .init(fileURLWithPath: outputPath)
} }
mutating func adjustPathsRelative(to folder: URL) {
if !contentPath.hasPrefix("/") {
contentPath = folder.appendingPathComponent(contentPath).resolvingFolderTraversal()!.path
}
if !outputPath.hasPrefix("/") {
outputPath = folder.appendingPathComponent(outputPath).resolvingFolderTraversal()!.path
}
}
func printOverview() {
print(" Source folder: \(contentDirectory.path)")
print(" Output folder: \(outputDirectory.path)")
print(" Page width: \(pageImageWidth)")
print(" Full-screen width: \(fullScreenImageWidth)")
print(" Minify JavaScript: \(minifyJavaScript)")
print(" Minify CSS: \(minifyCSS)")
print(" Create markdown files: \(createMdFilesIfMissing)")
}
} }

View File

@ -1,426 +0,0 @@
import Foundation
import CryptoKit
import AppKit
final class FileSystem {
private static let tempFileName = "temp.bin"
private let input: URL
private let output: URL
private let source = "FileSystem"
private let images: ImageGenerator
private var tempFile: URL {
input.appendingPathComponent(FileSystem.tempFileName)
}
/**
All files which should be copied to the output folder
*/
private var requiredFiles: Set<String> = []
/**
The files marked as external in element metadata.
Files included here are not generated, since they are assumed to be added separately.
*/
private var externalFiles: Set<String> = []
/**
The files marked as expected, i.e. they exist after the generation is completed.
The key of the dictionary is the file path, the value is the file providing the link
*/
private var expectedFiles: [String : String] = [:]
/**
All pages without content which have been created
*/
private var emptyPages: Set<String> = []
/**
All pages which have `status` set to ``PageState.draft``
*/
private var draftPages: Set<String> = []
/**
All paths to page element folders, indexed by their unique id.
This relation is used to generate relative links to pages using the ``Element.id`
*/
private var pagePaths: [String: String] = [:]
/**
The image creation tasks.
The key is the destination path.
*/
private var imageTasks: [String : ImageOutput] = [:]
/**
The paths to all pages which were changed
*/
private var generatedPages: Set<String> = []
init(in input: URL, to output: URL) {
self.input = input
self.output = output
self.images = .init(input: input, output: output)
}
func urlInOutputFolder(_ path: String) -> URL {
output.appendingPathComponent(path)
}
func urlInContentFolder(_ path: String) -> URL {
input.appendingPathComponent(path)
}
private func exists(_ url: URL) -> Bool {
FileManager.default.fileExists(atPath: url.path)
}
func dataOfRequiredFile(atPath path: String, source: String) -> Data? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
log.failedToOpen(path, requiredBy: source, error: nil)
return nil
}
do {
return try Data(contentsOf: url)
} catch {
log.failedToOpen(path, requiredBy: source, error: error)
return nil
}
}
func dataOfOptionalFile(atPath path: String, source: String) -> Data? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
return nil
}
do {
return try Data(contentsOf: url)
} catch {
log.failedToOpen(path, requiredBy: source, error: error)
return nil
}
}
func contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
if createEmptyFileIfMissing {
try? Data().write(to: url)
}
return nil
}
do {
return try String(contentsOf: url)
} catch {
log.failedToOpen(path, requiredBy: source, error: error)
return nil
}
}
func writeDetectedFileChangesToDisk() {
images.writeDetectedFileChangesToDisk()
}
// MARK: Images
@discardableResult
func requireImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
images.requireImage(at: destination, generatedFrom: source, requiredBy: path, width: width, height: desiredHeight)
}
func createImages() {
images.createImages()
}
// MARK: File copying
/**
Add a file as required, so that it will be copied to the output directory.
*/
func require(file: String) {
let url = input.appendingPathComponent(file)
guard url.exists, url.isDirectory else {
requiredFiles.insert(file)
return
}
do {
try FileManager.default
.contentsOfDirectory(atPath: url.path)
.forEach {
// Recurse into subfolders
require(file: file + "/" + $0)
}
} catch {
log.add(error: "Failed to read folder \(file): \(error)", source: source)
}
}
/**
Mark a file as explicitly missing.
This is done for the `externalFiles` entries in metadata,
to indicate that these files will be copied to the output folder manually.
*/
func exclude(file: String) {
externalFiles.insert(file)
}
/**
Mark a file as expected to be present in the output folder after generation.
This is done for all links between pages, which only exist after the pages have been generated.
*/
func expect(file: String, source: String) {
expectedFiles[file] = source
}
func copyRequiredFiles() {
var copiedFiles = Set<String>()
for file in requiredFiles {
let cleanPath = cleanRelativeURL(file)
let sourceUrl = input.appendingPathComponent(cleanPath)
let destinationUrl = output.appendingPathComponent(cleanPath)
guard sourceUrl.exists else {
if !isExternal(file: file) {
log.add(error: "Missing required file", source: cleanPath)
}
continue
}
if copyFileIfChanged(from: sourceUrl, to: destinationUrl) {
copiedFiles.insert(file)
}
}
try? tempFile.delete()
for (file, source) in expectedFiles {
guard !isExternal(file: file) else {
continue
}
let cleanPath = cleanRelativeURL(file)
let destinationUrl = output.appendingPathComponent(cleanPath)
if !destinationUrl.exists {
log.add(error: "Missing \(cleanPath)", source: source)
}
}
guard !copiedFiles.isEmpty else {
print("No required files copied")
return
}
print("\(copiedFiles.count) required files copied:")
for file in copiedFiles.sorted() {
print(" " + file)
}
}
private func copyFileIfChanged(from sourceUrl: URL, to destinationUrl: URL) -> Bool {
guard configuration.minifyCSSandJS else {
return copyBinaryFileIfChanged(from: sourceUrl, to: destinationUrl)
}
switch sourceUrl.pathExtension.lowercased() {
case "js":
return minifyJS(at: sourceUrl, andWriteTo: destinationUrl)
case "css":
return minifyCSS(at: sourceUrl, andWriteTo: destinationUrl)
default:
return copyBinaryFileIfChanged(from: sourceUrl, to: destinationUrl)
}
}
private func copyBinaryFileIfChanged(from sourceUrl: URL, to destinationUrl: URL) -> Bool {
do {
let data = try Data(contentsOf: sourceUrl)
return writeIfChanged(data, to: destinationUrl)
} catch {
log.add(error: "Failed to read data at \(sourceUrl.path)", source: source, error: error)
return false
}
}
private func minifyJS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool {
let command = "uglifyjs \(sourceUrl.path) > \(tempFile.path)"
do {
_ = try safeShell(command)
return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl)
} catch {
log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source)
return false
}
}
private func minifyCSS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool {
let command = "cleancss \(sourceUrl.path) -o \(tempFile.path)"
do {
_ = try safeShell(command)
return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl)
} catch {
log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source)
return false
}
}
private func cleanRelativeURL(_ raw: String) -> String {
let raw = raw.dropAfterLast("#") // Clean links to page content
guard raw.contains("..") else {
return raw
}
var result: [String] = []
for component in raw.components(separatedBy: "/") {
if component == ".." {
_ = result.popLast()
} else {
result.append(component)
}
}
return result.joined(separator: "/")
}
/**
Check if a file is marked as external.
Also checks for sub-paths of the file, e.g if the folder `docs` is marked as external,
then files like `docs/index.html` are also found to be external.
- Note: All paths are either relative to root (no leading slash) or absolute paths of the domain (leading slash)
*/
func isExternal(file: String) -> Bool {
// Deconstruct file path
var path = ""
for part in file.components(separatedBy: "/") {
guard part != "" else {
continue
}
if path == "" {
path = part
} else {
path += "/" + part
}
if externalFiles.contains(path) {
return true
}
}
return false
}
func printExternalFiles() {
guard !externalFiles.isEmpty else {
return
}
print("\(externalFiles.count) external resources needed:")
for file in externalFiles.sorted() {
print(" " + file)
}
}
// MARK: Pages
func isEmpty(page: String) {
emptyPages.insert(page)
}
func printEmptyPages() {
guard !emptyPages.isEmpty else {
return
}
print("\(emptyPages.count) empty pages:")
for page in emptyPages.sorted() {
print(" " + page)
}
}
func isDraft(path: String) {
draftPages.insert(path)
}
func printDraftPages() {
guard !draftPages.isEmpty else {
return
}
print("\(draftPages.count) drafts:")
for page in draftPages.sorted() {
print(" " + page)
}
}
func add(page: String, id: String) {
if let existing = pagePaths[id] {
log.add(error: "Conflicting id with \(existing)", source: page)
}
pagePaths[id] = page
}
func getPage(for id: String) -> String? {
pagePaths[id]
}
func generated(page: String) {
generatedPages.insert(page)
}
func printGeneratedPages() {
guard !generatedPages.isEmpty else {
print("No pages modified")
return
}
print("\(generatedPages.count) pages modified")
for page in generatedPages.sorted() {
print(" " + page)
}
}
// MARK: Writing files
@discardableResult
func writeIfChanged(_ data: Data, to url: URL) -> Bool {
// Only write changed files
if url.exists, let oldContent = try? Data(contentsOf: url), data == oldContent {
return false
}
do {
try data.createFolderAndWrite(to: url)
return true
} catch {
log.add(error: "Failed to write file", source: url.path, error: error)
return false
}
}
@discardableResult
func write(_ string: String, to url: URL) -> Bool {
let data = string.data(using: .utf8)!
return writeIfChanged(data, to: url)
}
// MARK: Running other tasks
@discardableResult
func safeShell(_ command: String) throws -> String {
let task = Process()
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
task.arguments = ["-cl", command]
task.executableURL = URL(fileURLWithPath: "/bin/zsh")
task.standardInput = nil
try task.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)!
return output
}
}

View File

@ -3,14 +3,10 @@ import CryptoKit
final class FileUpdateChecker { final class FileUpdateChecker {
private static let hashesFileName = "hashes.json" private let hashesFileName = "hashes.json"
private let input: URL private let input: URL
private var hashesFile: URL {
input.appendingPathComponent(FileUpdateChecker.hashesFileName)
}
/** /**
The hashes of all accessed files from the previous run The hashes of all accessed files from the previous run
@ -25,35 +21,41 @@ final class FileUpdateChecker {
*/ */
private var accessedFiles: [String : Data] = [:] private var accessedFiles: [String : Data] = [:]
private var source: String { var numberOfFilesLoaded: Int {
"FileUpdateChecker" previousFiles.count
}
var numberOfFilesAccessed: Int {
accessedFiles.count
} }
init(input: URL) { init(input: URL) {
self.input = input self.input = input
guard hashesFile.exists else { }
log.add(info: "No file hashes loaded, regarding all content as new", source: source)
return enum LoadResult {
case notLoaded
case loaded
case failed(String)
}
func loadPreviousRun(from folder: URL) -> LoadResult {
let url = folder.appendingPathComponent(hashesFileName)
guard url.exists else {
return .notLoaded
} }
let data: Data let data: Data
do { do {
data = try Data(contentsOf: hashesFile) data = try Data(contentsOf: url)
} catch { } catch {
log.add( return .failed("Failed to read hashes from last run: \(error)")
warning: "File hashes could not be read, regarding all content as new",
source: source,
error: error)
return
} }
do { do {
self.previousFiles = try JSONDecoder().decode(from: data) self.previousFiles = try JSONDecoder().decode(from: data)
} catch { } catch {
log.add( return .failed("Failed to decode hashes from last run: \(error)")
warning: "File hashes could not be decoded, regarding all content as new",
source: source,
error: error)
return
} }
return .loaded
} }
func fileHasChanged(at path: String) -> Bool { func fileHasChanged(at path: String) -> Bool {
@ -73,16 +75,18 @@ final class FileUpdateChecker {
accessedFiles[path] = SHA256.hash(data: data).data accessedFiles[path] = SHA256.hash(data: data).data
} }
func writeDetectedFileChanges(to folder: URL) -> String? {
func writeDetectedFileChangesToDisk() { let url = folder.appendingPathComponent(hashesFileName)
do { do {
let encoder = JSONEncoder() let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(accessedFiles) let data = try encoder.encode(accessedFiles)
try data.write(to: hashesFile) try data.write(to: url)
return nil
} catch { } catch {
log.add(warning: "Failed to save file hashes", source: source, error: error) return "Failed to save file hashes: \(error)"
}
} }
} }
} var notFound = 0

View File

@ -1,287 +0,0 @@
import Foundation
import AppKit
import CryptoKit
private struct ImageJob {
let destination: String
let width: Int
let path: String
}
final class ImageGenerator {
/**
The path to the input folder.
*/
private let input: URL
/**
The path to the output folder
*/
private let output: URL
/**
The images to generate.
The key is the image source path relative to the input folder, and the values are the destination path (relative to the output folder) and the required image width.
*/
private var imageJobs: [String : [ImageJob]] = [:]
/**
The images which could not be found, but are required for the site.
The key is the image path, and the value is the page that requires it.
*/
private var missingImages: [String : String] = [:]
/**
All warnings produced for images during generation
*/
private var imageWarnings: Set<String> = []
/**
All images required by the site.
The values are the destination paths of the images, relative to the output folder
*/
private var requiredImages: Set<String> = []
/**
All images modified or created during this generator run.
*/
private var generatedImages: Set<String> = []
/**
A cache to get the size of source images, so that files don't have to be loaded multiple times.
The key is the absolute source path, and the value is the image size
*/
private var imageSizeCache: [String : NSSize] = [:]
private var fileUpdates: FileUpdateChecker
init(input: URL, output: URL) {
self.fileUpdates = FileUpdateChecker(input: input)
self.input = input
self.output = output
}
func writeDetectedFileChangesToDisk() {
fileUpdates.writeDetectedFileChangesToDisk()
}
private func getImageSize(atPath path: String) -> NSSize? {
if let size = imageSizeCache[path] {
return size
}
guard let image = getImage(atPath: path) else {
return nil
}
let size = image.size
imageSizeCache[path] = size
return size
}
private func getImage(atPath path: String) -> NSImage? {
guard let data = getData(atPath: path) else {
log.add(error: "Failed to load file", source: path)
return nil
}
guard let image = NSImage(data: data) else {
log.add(error: "Failed to read image", source: path)
return nil
}
return image
}
private func getData(atPath path: String) -> Data? {
let url = input.appendingPathComponent(path)
guard url.exists else {
return nil
}
do {
let data = try Data(contentsOf: url)
fileUpdates.didLoad(data, at: path)
return data
} catch {
log.add(error: "Failed to read data", source: path, error: error)
return nil
}
}
func requireImage(at destination: String, generatedFrom source: String, requiredBy path: String, width: Int, height: Int?) -> NSSize {
requiredImages.insert(destination)
let height = height.unwrapped(CGFloat.init)
let sourceUrl = input.appendingPathComponent(source)
guard sourceUrl.exists else {
missingImages[source] = path
return .zero
}
guard let imageSize = getImageSize(atPath: source) else {
missingImages[source] = path
return .zero
}
let scaledSize = imageSize.scaledDown(to: CGFloat(width))
// Check desired height, then we can forget about it
if let height = height {
let expectedHeight = scaledSize.width / CGFloat(width) * height
if abs(expectedHeight - scaledSize.height) > 2 {
addWarning("Invalid height (\(scaledSize.height) instead of \(expectedHeight))", destination: destination, path: path)
}
}
let job = ImageJob(destination: destination, width: width, path: path)
guard let existingSource = imageJobs[source] else {
imageJobs[source] = [job]
return scaledSize
}
guard let existingJob = existingSource.first(where: { $0.destination == destination}) else {
imageJobs[source] = existingSource + [job]
return scaledSize
}
if existingJob.width != width {
addWarning("Multiple image widths (\(existingJob.width) and \(width))", destination: destination, path: "\(existingJob.path) and \(path)")
}
return scaledSize
}
func createImages() {
for (source, jobs) in imageJobs.sorted(by: { $0.key < $1.key }) {
create(images: jobs, from: source)
}
printMissingImages()
printImageWarnings()
printGeneratedImages()
printTotalImageCount()
}
private func printMissingImages() {
guard !missingImages.isEmpty else {
return
}
print("\(missingImages.count) missing images:")
for (source, path) in missingImages {
print(" \(source) (required by \(path))")
}
}
private func printImageWarnings() {
guard !imageWarnings.isEmpty else {
return
}
print("\(imageWarnings.count) image warnings:")
for imageWarning in imageWarnings {
print(imageWarning)
}
}
private func printGeneratedImages() {
guard !generatedImages.isEmpty else {
return
}
print("\(generatedImages.count) images generated:")
for image in generatedImages {
print(" " + image)
}
}
private func printTotalImageCount() {
print("\(requiredImages.count) images")
}
private func addWarning(_ message: String, destination: String, path: String) {
let warning = " \(destination): \(message) required by \(path)"
imageWarnings.insert(warning)
}
private func addWarning(_ message: String, job: ImageJob) {
addWarning(message, destination: job.destination, path: job.path)
}
private func isMissing(_ job: ImageJob) -> Bool {
!output.appendingPathComponent(job.destination).exists
}
private func create(images: [ImageJob], from source: String) {
// Only load image if required
let imageHasChanged = fileUpdates.fileHasChanged(at: source)
guard imageHasChanged || images.contains(where: isMissing) else {
return
}
guard let image = getImage(atPath: source) else {
missingImages[source] = images.first?.path
return
}
let jobs = imageHasChanged ? images : images.filter(isMissing)
// Update all images
jobs.forEach { job in
// Prevent memory overflow due to repeated NSImage operations
autoreleasepool {
create(job: job, from: image, source: source)
}
}
}
private func create(job: ImageJob, from image: NSImage, source: String) {
let destinationUrl = output.appendingPathComponent(job.destination)
// Ensure that image file is supported
let ext = destinationUrl.pathExtension.lowercased()
guard ImageType(fileExtension: ext) != nil else {
fatalError()
}
let destinationExtension = destinationUrl.pathExtension.lowercased()
guard let type = ImageType(fileExtension: destinationExtension)?.fileType else {
addWarning("Invalid image extension \(destinationExtension)", job: job)
return
}
let desiredWidth = CGFloat(job.width)
let sourceRep = image.representations[0]
let destinationSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh)
.scaledDown(to: desiredWidth)
// create NSBitmapRep manually, if using cgImage, the resulting size is wrong
let rep = NSBitmapImageRep(bitmapDataPlanes: nil,
pixelsWide: Int(destinationSize.width),
pixelsHigh: Int(destinationSize.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: NSColorSpaceName.deviceRGB,
bytesPerRow: Int(destinationSize.width) * 4,
bitsPerPixel: 32)!
let ctx = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
image.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height))
ctx?.flushGraphics()
NSGraphicsContext.restoreGraphicsState()
// Get NSData, and save it
guard let data = rep.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
addWarning("Failed to get data", job: job)
return
}
do {
try data.createFolderAndWrite(to: destinationUrl)
} catch {
addWarning("Failed to write image (\(error))", job: job)
return
}
generatedImages.insert(job.destination)
}
}

View File

@ -0,0 +1,14 @@
import Foundation
struct ImageJob {
let destination: String
let width: Int
let path: String
let quality: Float
let alwaysGenerate: Bool
}

View File

@ -0,0 +1,66 @@
import Foundation
import AppKit
final class ImageReader {
/// The content folder where the input data is stored
let contentFolder: URL
private let fileUpdates: FileUpdateChecker
let runDataFolder: URL
init(in input: URL, runFolder: URL, fileUpdates: FileUpdateChecker) {
self.contentFolder = input
self.runDataFolder = runFolder
self.fileUpdates = fileUpdates
}
var numberOfFilesLoaded: Int {
fileUpdates.numberOfFilesLoaded
}
var numberOfFilesAccessed: Int {
fileUpdates.numberOfFilesAccessed
}
func loadData() -> FileUpdateChecker.LoadResult {
fileUpdates.loadPreviousRun(from: runDataFolder)
}
func writeDetectedFileChangesToDisk() -> String? {
fileUpdates.writeDetectedFileChanges(to: runDataFolder)
}
func imageHasChanged(at path: String) -> Bool {
fileUpdates.fileHasChanged(at: path)
}
func getImage(atPath path: String) -> NSImage? {
guard let data = getData(atPath: path) else {
// TODO: log.error("Failed to load file", source: path)
return nil
}
guard let image = NSImage(data: data) else {
// TODO: log.error("Failed to read image", source: path)
return nil
}
return image
}
private func getData(atPath path: String) -> Data? {
let url = contentFolder.appendingPathComponent(path)
guard url.exists else {
return nil
}
do {
let data = try Data(contentsOf: url)
fileUpdates.didLoad(data, at: path)
return data
} catch {
// TODO: log.error("Failed to read data: \(error)", source: path)
return nil
}
}
}

View File

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

View File

@ -46,123 +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? {
if let customFile = customFile {
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
}
guard let thumbnail = Element.findThumbnail(for: language, in: folder) else {
// Link preview images are not necessarily required
return nil
}
return thumbnail
}
func moreLinkText(_ elementText: String?, parent parentText: String?, source: String) -> String {
if let elementText = elementText {
return elementText
}
let standard = Element.LocalizedMetadata.moreLinkDefaultText
guard let parentText = parentText, parentText != standard else {
add(error: "Missing property 'moreLinkText'", source: source)
return standard
}
return parentText
}
func date(from string: String?, property: String, source: String) -> Date? {
guard let string = string else {
return nil
}
guard let date = ValidationLog.metadataDate.date(from: string) else {
add(warning: "Invalid date string '\(string)' for property '\(property)'", source: source)
return nil
}
return date
}
private static let metadataDate: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yy"
return df
}()
} }

View File

@ -3,6 +3,7 @@ import Foundation
enum VideoType: String, CaseIterable { enum VideoType: String, CaseIterable {
case mp4 case mp4
case m4v case m4v
case webm
var htmlType: String { var htmlType: String {
switch self { switch self {
@ -10,6 +11,8 @@ enum VideoType: String, CaseIterable {
return "video/mp4" return "video/mp4"
case .m4v: case .m4v:
return "video/mp4" return "video/mp4"
case .webm:
return "video/webm"
} }
} }
} }

View File

@ -1,11 +1,9 @@
import Foundation import Foundation
typealias SVGSelection = (x: Int, y: Int, width: Int, height: Int)
struct HTMLElementsGenerator { struct HTMLElementsGenerator {
init() {
}
func make(title: String, suffix: String) -> String { func make(title: String, suffix: String) -> String {
"\(title)<span class=\"suffix\">\(suffix)</span>" "\(title)<span class=\"suffix\">\(suffix)</span>"
} }
@ -37,20 +35,18 @@ struct HTMLElementsGenerator {
"\(text)<span class=\"icon-next\"></span>" "\(text)<span class=\"icon-next\"></span>"
} }
func svgImage(file: String) -> String { func image(file: String, width: Int, height: Int, altText: String) -> String {
""" let ratio = Float(width) / Float(height)
return """
<span class="image"> <span class="image">
<img src="\(file)"/> <img src="\(file)" loading="lazy" style="aspect-ratio:\(ratio)" alt="\(altText)"/>
</span> </span>
""" """
} }
func svgImage(file: String, x: Int, y: Int, width: Int, height: Int) -> String { func svgImage(file: String, part: SVGSelection, altText: String) -> String {
""" let path = "\(file)#svgView(viewBox(\(part.x), \(part.y), \(part.width), \(part.height)))"
<span class="image"> return image(file: path, width: part.width, height: part.height, altText: altText)
<img src="\(file)#svgView(viewBox(\(x), \(y), \(width), \(height)))" style="aspect-ratio:\(Float(width)/Float(height))"/>
</span>
"""
} }
func downloadButtons(_ buttons: [(file: String, text: String, downloadName: String?)]) -> String { func downloadButtons(_ buttons: [(file: String, text: String, downloadName: String?)]) -> String {
@ -103,8 +99,11 @@ struct HTMLElementsGenerator {
""" """
} }
func scriptInclude(path: String) -> String { func scriptInclude(path: String, asModule: Bool) -> String {
"<script src=\"\(path)\"></script>" if asModule {
return "<script type=\"module\" src=\"\(path)\"></script>"
}
return "<script src=\"\(path)\"></script>"
} }
func codeHighlightFooter() -> String { func codeHighlightFooter() -> String {

View File

@ -1,297 +0,0 @@
import Foundation
import Ink
import Splash
struct PageContentGenerator {
private let factory: TemplateFactory
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
init(factory: TemplateFactory) {
self.factory = factory
}
func generate(page: Element, language: String, content: String) -> (content: String, includesCode: Bool) {
var hasCodeContent = false
let imageModifier = Modifier(target: .images) { html, markdown in
processMarkdownImage(markdown: markdown, html: html, page: page, language: language)
}
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
if markdown.starts(with: "```swift") {
let code = markdown.between("```swift", and: "```").trimmed
return "<pre><code>" + swift.highlight(code) + "</pre></code>"
}
hasCodeContent = true
return html
}
let linkModifier = Modifier(target: .links) { html, markdown in
handleLink(page: page, language: language, html: html, markdown: markdown)
}
let htmlModifier = Modifier(target: .html) { html, markdown in
handleHTML(page: page, language: language, html: html, markdown: markdown)
}
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier, htmlModifier])
return (parser.html(from: content), hasCodeContent)
}
private func handleLink(page: Element, language: String, html: String, markdown: Substring) -> String {
let file = markdown.between("(", and: ")")
if file.hasPrefix("page:") {
let pageId = file.replacingOccurrences(of: "page:", with: "")
guard let pagePath = files.getPage(for: pageId) else {
log.add(warning: "Page id '\(pageId)' not found", source: page.path)
// Remove link since the page can't be found
return markdown.between("[", and: "]")
}
let fullPath = pagePath + Element.htmlPagePathAddition(for: language)
// Adjust file path to get the page url
let url = page.relativePathToOtherSiteElement(file: fullPath)
return html.replacingOccurrences(of: file, with: url)
}
if let filePath = page.nonAbsolutePathRelativeToRootForContainedInputFile(file) {
// The target of the page link must be present after generation is complete
files.expect(file: filePath, source: page.path)
}
return html
}
private func handleHTML(page: Element, language: String, html: String, markdown: Substring) -> String {
#warning("Check HTML code in markdown for required resources")
//print("[HTML] Found in page \(page.path):")
//print(markdown)
// Things to check:
// <img src=
// <a href=
//
return html
}
private func processMarkdownImage(markdown: Substring, html: String, page: Element, language: String) -> String {
// Split the markdown ![alt](file title)
// There are several known shorthand commands
// For images: ![left_title](file right_title)
// For videos: ![option1,option2,...](file)
// For svg with custom area: ![x,y,width,height](file.svg)
// For downloads: ![download](file1, text1; file2, text2, ...)
// For a simple boxes: ![box](title;body)
// A fancy page link: ![page](page_id)
// External pages: ![external](url1, text1; url2, text2, ...)
let fileAndTitle = markdown.between("(", and: ")")
let alt = markdown.between("[", and: "]").nonEmpty
if let alt = alt, let command = ShorthandMarkdownKey(rawValue: alt) {
return handleShortHandCommand(command, page: page, language: language, content: fileAndTitle)
}
let file = fileAndTitle.dropAfterFirst(" ")
let title = fileAndTitle.contains(" ") ? fileAndTitle.dropBeforeFirst(" ").nonEmpty : nil
let fileExtension = file.lastComponentAfter(".").lowercased()
if let _ = ImageType(fileExtension: fileExtension) {
return handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
}
if let _ = VideoType(rawValue: fileExtension) {
return handleVideo(page: page, file: file, optionString: alt)
}
if fileExtension == "svg" {
return handleSvg(page: page, file: file, area: alt)
}
return handleFile(page: page, file: file, fileExtension: fileExtension)
}
private func handleShortHandCommand(_ command: ShorthandMarkdownKey, page: Element, language: String, content: String) -> String {
switch command {
case .downloadButtons:
return handleDownloadButtons(page: page, content: content)
case .externalLink:
return handleExternalButtons(page: page, content: content)
case .includedHtml:
return handleExternalHTML(page: page, file: content)
case .box:
return handleSimpleBox(page: page, content: content)
case .pageLink:
return handlePageLink(page: page, language: language, pageId: content)
}
}
private func handleImage(page: Element, file: String, rightTitle: String?, leftTitle: String?) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
let size = files.requireImage(
source: imagePath,
destination: imagePath,
requiredBy: page.path,
width: configuration.pageImageWidth)
let imagePath2x = imagePath.insert("@2x", beforeLast: ".")
let file2x = file.insert("@2x", beforeLast: ".")
files.requireImage(
source: imagePath,
destination: imagePath2x,
requiredBy: page.path,
width: 2 * configuration.pageImageWidth)
let content: [PageImageTemplate.Key : String] = [
.image: file,
.image2x: file2x,
.width: "\(Int(size.width))",
.height: "\(Int(size.height))",
.leftText: leftTitle ?? "",
.rightText: rightTitle ?? ""]
return factory.image.generate(content)
}
private func handleVideo(page: Element, file: String, optionString: String?) -> String {
let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in
string.components(separatedBy: " ").compactMap { optionText in
guard let optionText = optionText.trimmed.nonEmpty else {
return nil
}
guard let option = PageVideoTemplate.VideoOption(rawValue: optionText) else {
log.add(warning: "Unknown video option \(optionText)", source: page.path)
return nil
}
return option
}
} ?? []
#warning("Check page folder for alternative video versions")
let sources: [PageVideoTemplate.VideoSource] = [(url: file, type: .mp4)]
let filePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: filePath)
return factory.video.generate(sources: sources, options: options)
}
private func handleSvg(page: Element, file: String, area: String?) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: imagePath)
guard let area = area else {
return factory.html.svgImage(file: file)
}
let parts = area.components(separatedBy: ",").map { $0.trimmed }
guard parts.count == 4,
let x = Int(parts[0].trimmed),
let y = Int(parts[1].trimmed),
let width = Int(parts[2].trimmed),
let height = Int(parts[3].trimmed) else {
log.add(warning: "Invalid area string for svg image", source: page.path)
return factory.html.svgImage(file: file)
}
return factory.html.svgImage(file: file, x: x, y: y, width: width, height: height)
}
private func handleFile(page: Element, file: String, fileExtension: String) -> String {
log.add(warning: "Unhandled file \(file) with extension \(fileExtension)", source: page.path)
return ""
}
private func handleDownloadButtons(page: Element, content: String) -> String {
let buttons = content
.components(separatedBy: ";")
.compactMap { button -> (file: String, text: String, downloadName: String?)? in
let parts = button.components(separatedBy: ",")
guard parts.count == 2 || parts.count == 3 else {
log.add(warning: "Invalid button definition", source: page.path)
return nil
}
let file = parts[0].trimmed
let title = parts[1].trimmed
let downloadName = parts.count == 3 ? parts[2].trimmed : nil
// Ensure that file is available
let filePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: filePath)
return (file, title, downloadName)
}
return factory.html.downloadButtons(buttons)
}
private func handleExternalButtons(page: Element, content: String) -> String {
let buttons = content
.components(separatedBy: ";")
.compactMap { button -> (url: String, text: String)? in
let parts = button.components(separatedBy: ",")
guard parts.count == 2 else {
log.add(warning: "Invalid external link definition", source: page.path)
return nil
}
let url = parts[0].trimmed
let title = parts[1].trimmed
return (url, title)
}
return factory.html.externalButtons(buttons)
}
private func handleExternalHTML(page: Element, file: String) -> String {
let url = page.inputFolder.appendingPathComponent(file)
guard url.exists else {
log.add(error: "File \(file) not found", source: page.path)
return ""
}
do {
return try String(contentsOf: url)
} catch {
log.add(error: "File \(file) could not be read", source: page.path, error: error)
return ""
}
}
private func handleSimpleBox(page: Element, content: String) -> String {
let parts = content.components(separatedBy: ";")
guard parts.count > 1 else {
log.add(error: "Invalid box specification", source: page.path)
return ""
}
let title = parts[0]
let text = parts.dropFirst().joined(separator: ";")
return factory.makePlaceholder(title: title, text: text)
}
private func handlePageLink(page: Element, language: String, pageId: String) -> String {
guard let linkedPage = siteRoot.find(pageId) else {
log.add(warning: "Page id '\(pageId)' not found", source: page.path)
// Remove link since the page can't be found
return ""
}
var content = [PageLinkTemplate.Key: String]()
content[.url] = page.relativePathToOtherSiteElement(file: linkedPage.fullPageUrl(for: language))
content[.title] = linkedPage.title(for: language)
let fullThumbnailPath = linkedPage.thumbnailFilePath(for: language)
let relativeImageUrl = page.relativePathToOtherSiteElement(file: fullThumbnailPath)
let metadata = linkedPage.localized(for: language)
if linkedPage.state.hasThumbnailLink {
let fullPageUrl = linkedPage.fullPageUrl(for: language)
let relativePageUrl = page.relativePathToOtherSiteElement(file: fullPageUrl)
content[.url] = "href=\"\(relativePageUrl)\""
}
content[.image] = relativeImageUrl
if let suffix = metadata.thumbnailSuffix {
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
} else {
content[.title] = metadata.title
}
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
let path = linkedPage.makePath(language: language, from: siteRoot)
content[.path] = factory.pageLink.makePath(components: path)
content[.description] = metadata.relatedContentText
if let parent = linkedPage.findParent(from: siteRoot), parent.thumbnailStyle == .large {
content[.className] = " related-page-link-large"
}
// We assume that the thumbnail images are already required by overview pages.
return factory.pageLink.generate(content)
}
}

View File

@ -4,15 +4,17 @@ struct OverviewPageGenerator {
private let factory: LocalizedSiteTemplate private let factory: LocalizedSiteTemplate
init(factory: LocalizedSiteTemplate) { private let results: GenerationResultsHandler
init(factory: LocalizedSiteTemplate, results: GenerationResultsHandler) {
self.factory = factory self.factory = factory
self.results = results
} }
func generate( func generate(
section: Element, section: Element,
language: String) { language: String) {
let path = section.localizedPath(for: language) let path = section.localizedPath(for: language)
let url = files.urlInOutputFolder(path)
let metadata = section.localized(for: language) let metadata = section.localized(for: language)
@ -23,14 +25,12 @@ struct OverviewPageGenerator {
sectionUrl: section.sectionUrl(for: language), sectionUrl: section.sectionUrl(for: language),
languageButton: languageButton, languageButton: languageButton,
page: section) page: section)
content[.language] = language
content[.contentClass] = "overview" content[.contentClass] = "overview"
content[.header] = makeHeader(page: section, metadata: metadata, language: language) content[.header] = makeHeader(page: section, metadata: metadata, language: language)
content[.content] = makeContent(section: section, language: language) content[.content] = makeContent(section: section, language: language)
content[.footer] = section.customFooterContent() content[.footer] = results.getContentOfOptionalFile(at: section.additionalFooterContentPath, source: section.path)
guard factory.page.generate(content, to: url) else { factory.page.generate(content, to: path, source: section.path)
return
}
files.generated(page: path)
} }
private func makeContent(section: Element, language: String) -> String { private func makeContent(section: Element, language: String) -> String {

View File

@ -2,19 +2,84 @@ import Foundation
struct OverviewSectionGenerator { struct OverviewSectionGenerator {
private let multipleSectionsTemplate: OverviewSectionTemplate private let factory: TemplateFactory
private let singleSectionsTemplate: OverviewSectionCleanTemplate
private let generator: ThumbnailListGenerator private let generator: ThumbnailListGenerator
init(factory: TemplateFactory) { private let siteRoot: Element
self.multipleSectionsTemplate = factory.overviewSection
self.singleSectionsTemplate = factory.overviewSectionClean private let results: GenerationResultsHandler
self.generator = ThumbnailListGenerator(factory: factory)
init(factory: TemplateFactory, siteRoot: Element, results: GenerationResultsHandler) {
self.factory = factory
self.siteRoot = siteRoot
self.results = results
self.generator = ThumbnailListGenerator(factory: factory, results: results)
} }
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)
let firstSection = firstSectionContent(for: parent, language: language, sectionItemCount: sectionItemCount)
return firstSection + content
}
private func firstSectionContent(for element: Element, language: String, sectionItemCount: Int) -> String {
guard element.needsFirstSection else {
return ""
}
let metadata = element.localized(for: language)
var result = ""
if element.showMostRecentSection {
let shownElements = element.mostRecentElements(4).enumerated().map { (number, child) in
makeSlideshowItem(child, parent: element, language: language, number: number)
}
let title = metadata.mostRecentTitle ?? "Recent"
result = factory.slideshow.generate([.content: shownElements.joined(separator: "\n"), .title: title])
}
if !element.featuredPages.isEmpty {
let elements = element.featuredPages.compactMap { id -> Element? in
guard let linked = siteRoot.find(id) else {
results.warning("Unknown id '\(id)' in 'featuredPages'", source: element.path)
return nil
}
return linked
}.prefix(4).enumerated().map { number, page in
makeSlideshowItem(page, parent: element, language: language, number: number)
}
let title = metadata.featuredTitle ?? "Featured"
result += factory.slideshow.generate([.content: elements.joined(separator: "\n"), .title: title])
}
return factory.slideshows.generate([.content : result])
}
private func makeSlideshowItem(_ item: Element, parent: Element, language: String, number: Int) -> String {
let metadata = item.localized(for: language)
var content = [SlideshowImageTemplate.Key : String]()
content[.number] = "\(number + 1)"
content[.altText] = metadata.linkPreviewDescription
if item.state.hasThumbnailLink {
let fullPageUrl = item.fullPageUrl(for: language)
let relativePageUrl = parent.relativePathToFileWithPath(fullPageUrl)
content[.url] = "href=\"\(relativePageUrl)\""
}
// Image already assumed to be generated
let (_, thumbnailDestPath) = item.thumbnailFilePath(for: language)
let thumbnailDestNoExtension = thumbnailDestPath.dropAfterLast(".")
content[.image] = parent.relativePathToFileWithPath(thumbnailDestNoExtension)
if let suffix = metadata.thumbnailSuffix {
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
} else {
content[.title] = metadata.title
}
let path = item.makePath(language: language, from: siteRoot)
content[.subtitle] = factory.pageLink.makePath(components: path)
return factory.slideshowImage.generate(content)
}
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)
@ -30,7 +95,7 @@ struct OverviewSectionGenerator {
style: section.thumbnailStyle) style: section.thumbnailStyle)
content[.more] = metadata.moreLinkText content[.more] = metadata.moreLinkText
return multipleSectionsTemplate.generate(content) return factory.overviewSection.generate(content)
} }
.joined(separator: "\n") .joined(separator: "\n")
} }
@ -42,6 +107,6 @@ struct OverviewSectionGenerator {
parent: section, parent: section,
language: language, language: language,
style: section.thumbnailStyle) style: section.thumbnailStyle)
return singleSectionsTemplate.generate(content) return factory.overviewSectionClean.generate(content)
} }
} }

View File

@ -0,0 +1,466 @@
import Foundation
import Ink
import Splash
struct PageContentGenerator {
private let factory: TemplateFactory
private let siteRoot: Element
private let results: GenerationResultsHandler
init(factory: TemplateFactory, siteRoot: Element, results: GenerationResultsHandler) {
self.factory = factory
self.siteRoot = siteRoot
self.results = results
}
func generate(page: Element, language: String, content: String) -> (content: String, headers: RequiredHeaders) {
let parser = PageContentParser(
factory: factory,
siteRoot: siteRoot,
results: results,
page: page,
language: language)
return parser.generatePage(from: content)
}
}
final class PageContentParser {
private let pageLinkMarker = "page:"
private let largeImageIndicator = "*large*"
private let factory: TemplateFactory
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
private let siteRoot: Element
private let results: GenerationResultsHandler
private let page: Element
private let language: String
private var headers = RequiredHeaders()
private var largeImageCount: Int = 0
init(factory: TemplateFactory, siteRoot: Element, results: GenerationResultsHandler, page: Element, language: String) {
self.factory = factory
self.siteRoot = siteRoot
self.results = results
self.page = page
self.language = language
}
func generatePage(from content: String) -> (content: String, headers: RequiredHeaders) {
headers = .init()
let imageModifier = Modifier(target: .images) { html, markdown in
self.processMarkdownImage(markdown: markdown, html: html)
}
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
if markdown.starts(with: "```swift") {
let code = markdown.between("```swift", and: "```").trimmed
return "<pre><code>" + self.swift.highlight(code) + "</pre></code>"
}
self.headers.insert(.codeHightlighting)
return html
}
let linkModifier = Modifier(target: .links) { html, markdown in
self.handleLink(html: html, markdown: markdown)
}
let htmlModifier = Modifier(target: .html) { html, markdown in
self.handleHTML(html: html, markdown: markdown)
}
let headlinesModifier = Modifier(target: .headings) { html, markdown in
self.handleHeadlines(html: html, markdown: markdown)
}
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier, htmlModifier, headlinesModifier])
return (parser.html(from: content), headers)
}
private func handleLink(html: String, markdown: Substring) -> String {
let file = markdown.between("(", and: ")")
if file.hasPrefix(pageLinkMarker) {
let textToChange = file.dropAfterFirst("#")
let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "")
guard let pagePath = results.getPagePath(for: pageId, source: page.path, language: language) else {
// Remove link since the page can't be found
return markdown.between("[", and: "]")
}
// Adjust file path to get the page url
let url = page.relativePathToOtherSiteElement(file: pagePath)
return html.replacingOccurrences(of: textToChange, with: url)
}
if let filePath = page.nonAbsolutePathRelativeToRootForContainedInputFile(file) {
// The target of the page link must be present after generation is complete
results.expect(file: filePath, source: page.path)
}
return html
}
private func handleHTML(html: String, markdown: Substring) -> String {
// TODO: Check HTML code in markdown for required resources
//print("[HTML] Found in page \(page.path):")
//print(markdown)
// Things to check:
// <img src=
// <a href=
//
return html
}
private func handleHeadlines(html: String, markdown: Substring) -> String {
let id = markdown
.last(after: "#")
.trimmed
.filter { $0.isNumber || $0.isLetter || $0 == " " }
.lowercased()
.components(separatedBy: " ")
.filter { $0 != "" }
.joined(separator: "-")
let parts = html.components(separatedBy: ">")
return parts[0] + " id=\"\(id)\">" + parts.dropFirst().joined(separator: ">")
}
private func processMarkdownImage(markdown: Substring, html: String) -> String {
// Split the markdown ![alt](file title)
// There are several known shorthand commands
// For images: ![*large* left_title](file right_title)
// For videos: ![option1,option2,...](file)
// For svg with custom area: ![x,y,width,height](file.svg)
// For downloads: ![download](file1, text1; file2, text2, (download-name); ...)
// For a simple box: ![box](title;body)
// For 3D models: ![3d](file;Description)
// A fancy page link: ![page](page_id)
// External pages: ![external](url1, text1; url2, text2, ...)
guard let fileAndTitle = markdown.between(first: "](", andLast: ")").removingPercentEncoding else {
results.warning("Invalid percent encoding for markdown image", source: page.path)
return ""
}
let alt = markdown.between("[", and: "]").nonEmpty?.removingPercentEncoding
if let alt = alt, let command = ShorthandMarkdownKey(rawValue: alt) {
return handleShortHandCommand(command, content: fileAndTitle)
}
let file = fileAndTitle.dropAfterFirst(" ")
let title = fileAndTitle.contains(" ") ? fileAndTitle.dropBeforeFirst(" ").nonEmpty : nil
let fileExtension = file.lastComponentAfter(".").lowercased()
if let _ = ImageType(fileExtension: fileExtension) {
return handleImage(file: file, rightTitle: title, leftTitle: alt, largeImageCount: &largeImageCount)
}
if let _ = VideoType(rawValue: fileExtension) {
return handleVideo(file: file, optionString: alt)
}
switch fileExtension {
case "svg":
return handleSvg(file: file, area: alt)
case "gif":
return handleGif(file: file, altText: alt ?? "gif image")
default:
return handleFile(file: file, fileExtension: fileExtension)
}
}
private func handleShortHandCommand(_ command: ShorthandMarkdownKey, content: String) -> String {
switch command {
case .downloadButtons:
return handleDownloadButtons(content: content)
case .externalLink:
return handleExternalButtons(content: content)
case .includedHtml:
return handleExternalHTML(file: content)
case .box:
return handleSimpleBox(content: content)
case .pageLink:
return handlePageLink(pageId: content)
case .model3d:
return handle3dModel(content: content)
}
}
private func handleImage(file: String, rightTitle: String?, leftTitle: String?, largeImageCount: inout Int) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
let left: String
let createFullScreenVersion: Bool
if let leftTitle {
createFullScreenVersion = leftTitle.hasPrefix(largeImageIndicator)
left = leftTitle.dropBeforeFirst(largeImageIndicator).trimmed
} else {
left = ""
createFullScreenVersion = false
}
let size = results.requireFullSizeMultiVersionImage(
source: imagePath,
destination: imagePath,
requiredBy: page.path)
let altText = left.nonEmpty ?? rightTitle ?? "image"
var content = [PageImageTemplateKey : String]()
content[.altText] = altText
content[.image] = file.dropAfterLast(".")
content[.imageExtension] = file.lastComponentAfter(".")
content[.width] = "\(Int(size.width))"
content[.height] = "\(Int(size.height))"
content[.leftText] = left
content[.rightText] = rightTitle ?? ""
guard createFullScreenVersion else {
return factory.image.generate(content)
}
results.requireOriginalSizeImages(
source: imagePath,
destination: imagePath,
requiredBy: page.path)
largeImageCount += 1
content[.number] = "\(largeImageCount)"
return factory.largeImage.generate(content)
}
private func handleVideo(file: String, optionString: String?) -> String {
let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in
string.components(separatedBy: " ").compactMap { optionText -> PageVideoTemplate.VideoOption? in
guard let optionText = optionText.trimmed.nonEmpty else {
return nil
}
guard let option = PageVideoTemplate.VideoOption(rawValue: optionText) else {
results.warning("Unknown video option \(optionText)", source: page.path)
return nil
}
return option
}
} ?? []
let prefix = file.lastComponentAfter("/").dropAfterLast(".")
// Find all video files starting with the name of the video as a prefix
var sources: [PageVideoTemplate.VideoSource] = []
do {
let folder = results.contentFolder.appendingPathComponent(page.path)
let filesInFolder = try FileManager.default.contentsOfDirectory(atPath: folder.path)
sources += selectVideoFiles(with: prefix, from: filesInFolder)
} catch {
results.warning("Failed to check for additional videos", source: page.path)
}
// Also look in external files
sources += selectVideoFiles(with: prefix, from: page.externalFiles)
.map { (page.relativePathToFileWithPath($0.url), $0.type) }
// Require all video files
sources.forEach {
let path = page.pathRelativeToRootForContainedInputFile($0.url)
results.require(file: path, source: page.path)
}
// Sort, so that order of selection in browser is defined
sources.sort { $0.url < $1.url }
return factory.video.generate(sources: sources, options: options)
}
private func selectVideoFiles<T>(with prefix: String, from all: T) -> [PageVideoTemplate.VideoSource] where T: Sequence, T.Element == String {
all.compactMap {
guard $0.lastComponentAfter("/").hasPrefix(prefix) else {
return nil
}
guard let type = VideoType(rawValue: $0.lastComponentAfter(".").lowercased()) else {
return nil
}
return (url: $0, type: type)
}
}
private func handleGif(file: String, altText: String) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
results.require(file: imagePath, source: page.path)
guard let size = results.getImageSize(atPath: imagePath, source: page.path) else {
return ""
}
let width = Int(size.width)
let height = Int(size.height)
return factory.html.image(file: file, width: width, height: height, altText: altText)
}
private func handleSvg(file: String, area: String?) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
results.require(file: imagePath, source: page.path)
guard let size = results.getImageSize(atPath: imagePath, source: page.path) else {
return "" // Missing image warning already produced
}
let width = Int(size.width)
let height = Int(size.height)
var altText = "image " + file.lastComponentAfter("/")
guard let area = area else {
return factory.html.image(file: file, width: width, height: height, altText: altText)
}
let parts = area.components(separatedBy: ",").map { $0.trimmed }
switch parts.count {
case 1:
return factory.html.image(file: file, width: width, height: height, altText: parts[0])
case 4:
break
case 5:
altText = parts[4]
default:
results.warning("Invalid area string for svg image", source: page.path)
return factory.html.image(file: file, width: width, height: height, altText: altText)
}
guard let x = Int(parts[0]),
let y = Int(parts[1]),
let partWidth = Int(parts[2]),
let partHeight = Int(parts[3]) else {
results.warning("Invalid area string for svg image", source: page.path)
return factory.html.image(file: file, width: width, height: height, altText: altText)
}
let part = SVGSelection(x, y, partWidth, partHeight)
return factory.html.svgImage(file: file, part: part, altText: altText)
}
private func handleFile(file: String, fileExtension: String) -> String {
results.warning("Unhandled file \(file) with extension \(fileExtension)", source: page.path)
return ""
}
private func handleDownloadButtons(content: String) -> String {
let buttons = content
.components(separatedBy: ";")
.compactMap { button -> (file: String, text: String, downloadName: String?)? in
let parts = button.components(separatedBy: ",")
guard parts.count == 2 || parts.count == 3 else {
results.warning("Invalid download definition with \(parts)", source: page.path)
return nil
}
let file = parts[0].trimmed
let title = parts[1].trimmed
let downloadName = parts.count == 3 ? parts[2].trimmed : nil
// Ensure that file is available
let filePath = page.pathRelativeToRootForContainedInputFile(file)
results.require(file: filePath, source: page.path)
return (file, title, downloadName)
}
return factory.html.downloadButtons(buttons)
}
private func handleExternalButtons(content: String) -> String {
let buttons = content
.components(separatedBy: ";")
.compactMap { button -> (url: String, text: String)? in
let parts = button.components(separatedBy: ",")
guard parts.count == 2 else {
results.warning("Invalid external link definition", source: page.path)
return nil
}
guard let url = parts[0].trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
results.warning("Invalid external link \(parts[0].trimmed)", source: page.path)
return nil
}
let title = parts[1].trimmed
return (url, title)
}
return factory.html.externalButtons(buttons)
}
private func handleExternalHTML(file: String) -> String {
let path = page.pathRelativeToRootForContainedInputFile(file)
return results.getContentOfRequiredFile(at: path, source: page.path) ?? ""
}
private func handleSimpleBox(content: String) -> String {
let parts = content.components(separatedBy: ";")
guard parts.count > 1 else {
results.warning("Invalid box specification", source: page.path)
return ""
}
let title = parts[0]
let text = parts.dropFirst().joined(separator: ";")
return factory.makePlaceholder(title: title, text: text)
}
private func handlePageLink(pageId: String) -> String {
guard let linkedPage = siteRoot.find(pageId) else {
// Checking the page path will add it to the missing pages
_ = results.getPagePath(for: pageId, source: page.path, language: language)
// Remove link since the page can't be found
return ""
}
guard linkedPage.state == .standard else {
// Prevent linking to unpublished content
return ""
}
var content = [PageLinkTemplate.Key: String]()
content[.title] = linkedPage.title(for: language)
content[.altText] = ""
let fullThumbnailPath = linkedPage.thumbnailFilePath(for: language).destination
// Note: Here we assume that the thumbnail was already used elsewhere, so already generated
let relativeImageUrl = page.relativePathToOtherSiteElement(file: fullThumbnailPath)
let metadata = linkedPage.localized(for: language)
if linkedPage.state.hasThumbnailLink {
let fullPageUrl = linkedPage.fullPageUrl(for: language)
let relativePageUrl = page.relativePathToOtherSiteElement(file: fullPageUrl)
content[.url] = "href=\"\(relativePageUrl)\""
}
content[.image] = relativeImageUrl.dropAfterLast(".")
if let suffix = metadata.thumbnailSuffix {
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
} else {
content[.title] = metadata.title
}
let path = linkedPage.makePath(language: language, from: siteRoot)
content[.path] = factory.pageLink.makePath(components: path)
content[.description] = metadata.relatedContentText
if let parent = linkedPage.findParent(from: siteRoot), parent.thumbnailStyle == .large {
content[.className] = " related-page-link-large"
}
// We assume that the thumbnail images are already required by overview pages.
return factory.pageLink.generate(content)
}
private func handle3dModel(content: String) -> String {
let parts = content.components(separatedBy: ";")
guard parts.count > 1 else {
results.warning("Invalid 3d model specification", source: page.path)
return ""
}
let file = parts[0]
guard file.hasSuffix(".glb") else {
results.warning("Invalid 3d model file \(file) (must be .glb)", source: page.path)
return ""
}
// Ensure that file is available
let filePath = page.pathRelativeToRootForContainedInputFile(file)
results.require(file: filePath, source: page.path)
// Add required file to head
headers.insert(.modelViewer)
let description = parts.dropFirst().joined(separator: ";")
return """
<model-viewer alt="\(description)" src="\(file)" ar shadow-intensity="1" camera-controls touch-action="pan-y"></model-viewer>
"""
}
}

View File

@ -5,23 +5,31 @@ struct PageGenerator {
private let factory: LocalizedSiteTemplate private let factory: LocalizedSiteTemplate
init(factory: LocalizedSiteTemplate) { private let contentGenerator: PageContentGenerator
private let results: GenerationResultsHandler
init(factory: LocalizedSiteTemplate, siteRoot: Element, results: GenerationResultsHandler) {
self.factory = factory self.factory = factory
self.results = results
self.contentGenerator = PageContentGenerator(factory: factory.factory, siteRoot: siteRoot, results: results)
} }
func generate(page: Element, language: String, previousPage: Element?, nextPage: Element?) { func generate(page: Element, language: String, previousPage: Element?, nextPage: Element?) {
guard !page.isExternalPage else { guard !page.isExternalPage else {
results.didCompletePage()
return return
} }
let path = page.fullPageUrl(for: language) let path = page.fullPageUrl(for: language)
let inputContentPath = page.path + "/\(language).md" let inputContentPath = page.path + "/\(language).md"
let metadata = page.localized(for: language) let metadata = page.localized(for: language)
let nextLanguage = page.nextLanguage(for: language) let nextLanguage = page.nextLanguage(for: language)
let (pageContent, pageIncludesCode, pageIsEmpty) = makeContent( let (pageContent, additionalHeaders) = makeContent(
page: page, metadata: metadata, language: language, path: inputContentPath) page: page, metadata: metadata, language: language, path: inputContentPath)
var content = [PageTemplate.Key : String]() var content = [PageTemplate.Key : String]()
content[.head] = factory.pageHead.generate(page: page, language: language, includesCode: pageIncludesCode) content[.language] = language
content[.head] = factory.pageHead.generate(page: page, language: language, headers: additionalHeaders)
let sectionUrl = page.sectionUrl(for: language) let sectionUrl = page.sectionUrl(for: language)
content[.topBar] = factory.topBar.generate(sectionUrl: sectionUrl, languageButton: nextLanguage, page: page) content[.topBar] = factory.topBar.generate(sectionUrl: sectionUrl, languageButton: nextLanguage, page: page)
content[.contentClass] = "content" content[.contentClass] = "content"
@ -32,23 +40,17 @@ struct PageGenerator {
content[.previousPageUrl] = navLink(from: page, to: previousPage, language: language) content[.previousPageUrl] = navLink(from: page, to: previousPage, language: language)
content[.nextPageLinkText] = nextText(for: nextPage, language: language) content[.nextPageLinkText] = nextText(for: nextPage, language: language)
content[.nextPageUrl] = navLink(from: page, to: nextPage, language: language) content[.nextPageUrl] = navLink(from: page, to: nextPage, language: language)
content[.footer] = page.customFooterContent() content[.footer] = results.getContentOfOptionalFile(at: page.additionalFooterContentPath, source: page.path)
if pageIncludesCode { if additionalHeaders.contains(.codeHightlighting) {
let highlightCode = factory.factory.html.codeHighlightFooter() let highlightCode = factory.factory.html.codeHighlightFooter()
content[.footer] = (content[.footer].unwrapped { $0 + "\n" } ?? "") + highlightCode content[.footer] = (content[.footer].unwrapped { $0 + "\n" } ?? "") + highlightCode
} }
let url = files.urlInOutputFolder(path)
if page.state == .draft { if page.state == .draft {
files.isDraft(path: page.path) results.markPageAsDraft(page: page.path)
} else if pageIsEmpty, page.state != .hidden {
files.isEmpty(page: path)
} }
guard factory.page.generate(content, to: url) else { factory.page.generate(content, to: path, source: page.path)
return
}
files.generated(page: path)
} }
private func navLink(from element: Element, to destination: Element?, language: String) -> String? { private func navLink(from element: Element, to destination: Element?, language: String) -> String? {
@ -72,18 +74,14 @@ struct PageGenerator {
return factory.factory.html.makeNextText(text) return factory.factory.html.makeNextText(text)
} }
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, headers: RequiredHeaders) {
let create = configuration.createMdFilesIfMissing if let raw = results.getContentOfMdFile(at: 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 { return (content, includesCode)
let (content, includesCode) = PageContentGenerator(factory: factory.factory)
.generate(page: page, language: language, content: raw)
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)
} }
} }

View File

@ -2,15 +2,19 @@ import Foundation
struct PageHeadGenerator { struct PageHeadGenerator {
// TODO: Add to configuration
static let linkPreviewDesiredImageWidth = 1600 static let linkPreviewDesiredImageWidth = 1600
let factory: TemplateFactory let factory: TemplateFactory
init(factory: TemplateFactory) { private let results: GenerationResultsHandler
init(factory: TemplateFactory, results: GenerationResultsHandler) {
self.factory = factory self.factory = factory
self.results = results
} }
func generate(page: Element, language: String, includesCode: Bool = false) -> String { func generate(page: Element, language: String, headers: RequiredHeaders = .init()) -> String {
let metadata = page.localized(for: language) let metadata = page.localized(for: language)
var content = [PageHeadTemplate.Key : String]() var content = [PageHeadTemplate.Key : String]()
@ -21,28 +25,27 @@ struct PageHeadGenerator {
// Note: Generate separate destination link for the image, // Note: Generate separate destination link for the image,
// since we don't want a single large image for thumbnails. // since we don't want a single large image for thumbnails.
// Warning: Link preview source path must be relative to root // Warning: Link preview source path must be relative to root
let linkPreviewImageName = image.insert("-link", beforeLast: ".") 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( results.requireSingleImage(
source: sourceImagePath, source: sourceImagePath,
destination: destinationImagePath, destination: destinationImagePath,
requiredBy: page.path, requiredBy: page.path,
width: PageHeadGenerator.linkPreviewDesiredImageWidth) width: PageHeadGenerator.linkPreviewDesiredImageWidth)
content[.image] = factory.html.linkPreviewImage(file: linkPreviewImageName) content[.image] = factory.html.linkPreviewImage(file: linkPreviewImageName)
} }
content[.customPageContent] = page.customHeadContent() content[.customPageContent] = results.getContentOfOptionalFile(at: page.additionalHeadContentPath, source: page.path)
if includesCode {
let scriptPath = "assets/js/highlight.js" let head = (content[.customPageContent].unwrapped { [$0] } ?? []) + headers
.sorted { $0.rawValue < $1.rawValue }
.map { option in
let scriptPath = "assets/js/\(option.rawValue)"
let relative = page.relativePathToOtherSiteElement(file: scriptPath) let relative = page.relativePathToOtherSiteElement(file: scriptPath)
let includeText = factory.html.scriptInclude(path: relative) return factory.html.scriptInclude(path: relative, asModule: option.asModule)
if let head = content[.customPageContent] {
content[.customPageContent] = head + "\n" + includeText
} else {
content[.customPageContent] = includeText
}
} }
content[.customPageContent] = head.joined(separator: "\n")
return factory.pageHead.generate(content) return factory.pageHead.generate(content)
} }
} }

View File

@ -26,4 +26,9 @@ enum ShorthandMarkdownKey: String {
A pretty link to another page on the site. A pretty link to another page on the site.
*/ */
case pageLink = "page" case pageLink = "page"
/**
A 3D model to display
*/
case model3d = "3d"
} }

View File

@ -6,9 +6,12 @@ struct SiteGenerator {
let templates: TemplateFactory let templates: TemplateFactory
init() throws { let results: GenerationResultsHandler
let templatesFolder = files.urlInContentFolder("templates")
self.templates = try TemplateFactory(templateFolder: templatesFolder) init(results: GenerationResultsHandler) throws {
self.results = results
let templatesFolder = results.contentFolder.appendingPathComponent("templates")
self.templates = try TemplateFactory(templateFolder: templatesFolder, results: results)
} }
func generate(site: Element) { func generate(site: Element) {
@ -22,11 +25,12 @@ struct SiteGenerator {
let template = LocalizedSiteTemplate( let template = LocalizedSiteTemplate(
factory: templates, factory: templates,
language: language, language: language,
site: site) site: site,
results: results)
// Generate sections // Generate sections
let overviewGenerator = OverviewPageGenerator(factory: template) let overviewGenerator = OverviewPageGenerator(factory: template, results: results)
let pageGenerator = PageGenerator(factory: template) let pageGenerator = PageGenerator(factory: template, siteRoot: site, results: results)
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() {
@ -34,8 +38,7 @@ struct SiteGenerator {
elementsToProcess.append(contentsOf: element.linkedElements) elementsToProcess.append(contentsOf: element.linkedElements)
processAllFiles(for: element) processAllFiles(for: element)
if element.hasVisibleChildren {
if !element.elements.isEmpty {
overviewGenerator.generate(section: element, language: language) overviewGenerator.generate(section: element, language: language)
} else { } else {
pageGenerator.generate( pageGenerator.generate(
@ -48,10 +51,10 @@ struct SiteGenerator {
} }
private func processAllFiles(for element: Element) { private func processAllFiles(for element: Element) {
element.requiredFiles.forEach(files.require) element.externalFiles.forEach { results.exclude(file: $0, source: element.path) }
element.externalFiles.forEach(files.exclude) element.requiredFiles.forEach { results.require(file: $0, source: element.path) }
element.images.forEach { element.images.forEach {
files.requireImage( results.requireSingleImage(
source: $0.sourcePath, source: $0.sourcePath,
destination: $0.destinationPath, destination: $0.destinationPath,
requiredBy: element.path, requiredBy: element.path,

View File

@ -4,8 +4,11 @@ struct ThumbnailListGenerator {
private let factory: TemplateFactory private let factory: TemplateFactory
init(factory: TemplateFactory) { private let results: GenerationResultsHandler
init(factory: TemplateFactory, results: GenerationResultsHandler) {
self.factory = factory self.factory = factory
self.results = results
} }
func generateContent(items: [Element], parent: Element, language: String, style: ThumbnailStyle) -> String { func generateContent(items: [Element], parent: Element, language: String, style: ThumbnailStyle) -> String {
@ -14,8 +17,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 fullThumbnailPath = item.thumbnailFilePath(for: language)
let relativeImageUrl = parent.relativePathToFileWithPath(fullThumbnailPath)
let metadata = item.localized(for: language) let metadata = item.localized(for: language)
var content = [ThumbnailKey : String]() var content = [ThumbnailKey : String]()
@ -25,32 +27,27 @@ 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)
content[.altText] = metadata.linkPreviewDescription
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( results.requireMultiVersionImage(
source: fullThumbnailPath, source: thumbnailSourcePath,
destination: fullThumbnailPath, 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: fullThumbnailPath,
destination: fullThumbnailPath.insert("@2x", beforeLast: "."),
requiredBy: item.path,
width: style.width * 2,
desiredHeight: style.height * 2)
return factory.thumbnail(style: style).generate(content, shouldIndent: false) return factory.thumbnail(style: style).generate(content, shouldIndent: false)
} }
} }

View File

@ -0,0 +1,119 @@
import Foundation
func checkDependencies() -> Bool {
print("--- DEPENDENCIES -----------------------------------")
print(" ")
defer { print(" ") }
var valid = true
valid = checkImageOptimAvailability() && valid
valid = checkMagickAvailability() && valid
valid = checkCwebpAvailability() && valid
valid = checkAvifAvailability() && valid
valid = checkUglifyJsAvailability() && valid
valid = checkCleanCssAvailability() && valid
return valid
}
private func checkImageOptimAvailability() -> Bool {
do {
let output = try safeShell("imageoptim --version")
let version = output.components(separatedBy: ".").compactMap { Int($0.trimmed) }
if version.count > 1 {
print(" ImageOptim: \(version.map { "\($0)" }.joined(separator: "."))")
} else {
print(" ImageOptim: Not found, download app from https://imageoptim.com/mac and install using 'npm install imageoptim-cli'")
return false
}
} catch {
print(" ImageOptim: Failed to get version (\(error))")
return false
}
return true
}
private func checkMagickAvailability() -> Bool {
do {
let output = try safeShell("magick --version")
guard let version = output.components(separatedBy: "ImageMagick ").dropFirst().first?
.components(separatedBy: " ").first else {
print(" Magick: Not found, install using 'brew install imagemagick'")
return false
}
print(" Magick: \(version)")
} catch {
print(" Magick: Failed to get version (\(error))")
return false
}
return true
}
private func checkCwebpAvailability() -> Bool {
do {
let output = try safeShell("cwebp -version")
let version = output.components(separatedBy: ".").compactMap { Int($0.trimmed) }
if version.count > 1 {
print(" cwebp: \(version.map { "\($0)" }.joined(separator: "."))")
} else {
print(" cwebp: Not found, download from https://developers.google.com/speed/webp/download")
return false
}
} catch {
print(" cwebp: Failed to get version (\(error))")
return false
}
return true
}
private func checkAvifAvailability() -> Bool {
do {
let output = try safeShell("npx avif --version")
let version = output.components(separatedBy: ".").compactMap { Int($0.trimmed) }
if version.count > 1 {
print(" avif: \(version.map { "\($0)" }.joined(separator: "."))")
} else {
print(" avif: Not found, install using 'npm install avif'")
return false
}
} catch {
print(" avif: Failed to get version (\(error))")
return false
}
return true
}
private func checkUglifyJsAvailability() -> Bool {
do {
let output = try safeShell("uglifyjs --version")
let version = output.dropBeforeFirst("uglify-js").components(separatedBy: ".").compactMap { Int($0.trimmed) }
if version.count > 1 {
print(" uglify-js: \(version.map { "\($0)" }.joined(separator: "."))")
} else {
print("'\(output)'")
print(" uglify-js: Not found, install using 'npm install uglify-js'")
return false
}
} catch {
print(" uglify-js: Failed to get version (\(error))")
return false
}
return true
}
private func checkCleanCssAvailability() -> Bool {
do {
let output = try safeShell("cleancss --version")
let version = output.components(separatedBy: ".").compactMap { Int($0.trimmed) }
if version.count > 1 {
print(" cleancss: \(version.map { "\($0)" }.joined(separator: "."))")
} else {
print(" cleancss: Not found, install using 'npm install clean-css-cli'")
return false
}
} catch {
print(" cleancss: Failed to get version (\(error))")
return false
}
return true
}

View File

@ -0,0 +1,20 @@
import Foundation
struct FileData {
///The files marked as expected, i.e. they exist after the generation is completed. (`key`: file path, `value`: the file providing the link)
var expected: [String : String] = [:]
/// All files which should be copied to the output folder (`key`: The file path, `value`: The source requiring the file)
var toCopy: [String : String] = [:]
/// The files to minify when copying into output directory. (`key`: the file path relative to the content folder)
var toMinify: [String : (source: String, type: MinificationType)] = [:]
/**
The files marked as external in element metadata. (Key: File path, Value: source element)
Files included here are not generated, since they are assumed to be added separately.
*/
var external: [String : String] = [:]
}

View File

@ -0,0 +1,277 @@
import Foundation
final class FileGenerator {
let input: URL
let outputFolder: URL
let runFolder: URL
private let files: FileData
/// All files copied to the destination.
private var copiedFiles: Set<String> = []
/// Files which could not be read (`key`: file path relative to content)
private var unreadableFiles: [String : (source: String, message: String)] = [:]
/// Files which could not be written (`key`: file path relative to output folder)
private var unwritableFiles: [String : (source: String, message: String)] = [:]
private var failedMinifications: [(file: String, source: String, message: String)] = []
/// Non-existent files. `key`: file path, `value`: source element
private var missingFiles: [String : String] = [:]
private var minifiedFiles: [String] = []
private let numberOfFilesToCopy: Int
private var numberOfCopiedFiles = 0
private let numberOfFilesToMinify: Int
private var numberOfMinifiedFiles = 0
private var numberOfExistingFiles = 0
private var tempFile: URL {
runFolder.appendingPathComponent("temp")
}
init(input: URL, output: URL, runFolder: URL, files: FileData) {
self.input = input
self.outputFolder = output
self.runFolder = runFolder
self.files = files
numberOfFilesToCopy = files.toCopy.count
numberOfFilesToMinify = files.toMinify.count
}
func generate() {
copy(files: files.toCopy)
print(" Copied files: \(copiedFiles.count)/\(numberOfFilesToCopy) ")
minify(files: files.toMinify)
print(" Minified files: \(minifiedFiles.count)/\(numberOfFilesToMinify) ")
checkExpected(files: files.expected)
print(" Expected files: \(numberOfExistingFiles)/\(files.expected.count)")
print(" External files: \(files.external.count)")
print("")
}
private func didCopyFile() {
numberOfCopiedFiles += 1
print(" Copied files: \(numberOfCopiedFiles)/\(numberOfFilesToCopy) \r", terminator: "")
fflush(stdout)
}
private func didMinifyFile() {
numberOfMinifiedFiles += 1
print(" Minified files: \(numberOfMinifiedFiles)/\(numberOfFilesToMinify) \r", terminator: "")
fflush(stdout)
}
// MARK: File copies
private func copy(files: [String : String]) {
for (file, source) in files {
copyFileIfChanged(file: file, source: source)
}
}
private func copyFileIfChanged(file: String, source: String) {
let cleanPath = cleanRelativeURL(file)
let destinationUrl = outputFolder.appendingPathComponent(cleanPath)
defer { didCopyFile() }
guard copyIfChanged(cleanPath, to: destinationUrl, source: source) else {
return
}
copiedFiles.insert(cleanPath)
}
private func copyIfChanged(_ file: String, to destination: URL, source: String) -> Bool {
let url = input.appendingPathComponent(file)
do {
let data = try Data(contentsOf: url)
return writeIfChanged(data, file: file, source: source)
} catch {
markFileAsUnreadable(at: file, requiredBy: source, message: "\(error)")
return false
}
}
@discardableResult
func writeIfChanged(_ data: Data, file: String, source: String) -> Bool {
let url = outputFolder.appendingPathComponent(file)
// Only write changed files
if url.exists, let oldContent = try? Data(contentsOf: url), data == oldContent {
return false
}
do {
try data.createFolderAndWrite(to: url)
return true
} catch {
markFileAsUnwritable(at: file, requiredBy: source, message: "Failed to write file: \(error)")
return false
}
}
// MARK: File minification
private func minify(files: [String : (source: String, type: MinificationType)]) {
for (file, other) in files {
minify(file: file, source: other.source, type: other.type)
}
}
private func minify(file: String, source: String, type: MinificationType) {
let url = input.appendingPathComponent(file)
let command: String
switch type {
case .js:
command = "uglifyjs \(url.path) > \(tempFile.path)"
case .css:
command = "cleancss \(url.path) -o \(tempFile.path)"
}
defer {
didMinifyFile()
try? tempFile.delete()
}
let output: String
do {
output = try safeShell(command)
} catch {
failedMinifications.append((file, source, "Failed to minify with error: \(error)"))
return
}
guard tempFile.exists else {
failedMinifications.append((file, source, output))
return
}
let data: Data
do {
data = try Data(contentsOf: tempFile)
} catch {
markFileAsUnreadable(at: file, requiredBy: source, message: "\(error)")
return
}
if writeIfChanged(data, file: file, source: source) {
minifiedFiles.append(file)
}
}
private func cleanRelativeURL(_ raw: String) -> String {
let raw = raw.dropAfterLast("#") // Clean links to page content
guard raw.contains("..") else {
return raw
}
var result: [String] = []
for component in raw.components(separatedBy: "/") {
if component == ".." {
_ = result.popLast()
} else {
result.append(component)
}
}
return result.joined(separator: "/")
}
private func markFileAsUnreadable(at path: String, requiredBy source: String, message: String) {
unreadableFiles[path] = (source, message)
}
private func markFileAsUnwritable(at path: String, requiredBy source: String, message: String) {
unwritableFiles[path] = (source, message)
}
// MARK: File expectationts
private func checkExpected(files: [String: String]) {
for (file, source) in files {
guard !isExternal(file: file) else {
numberOfExistingFiles += 1
continue
}
let cleanPath = cleanRelativeURL(file)
let destinationUrl = outputFolder.appendingPathComponent(cleanPath)
guard destinationUrl.exists else {
markFileAsMissing(at: cleanPath, requiredBy: source)
continue
}
numberOfExistingFiles += 1
}
}
private func markFileAsMissing(at path: String, requiredBy source: String) {
missingFiles[path] = source
}
/**
Check if a file is marked as external.
Also checks for sub-paths of the file, e.g if the folder `docs` is marked as external,
then files like `docs/index.html` are also found to be external.
- Note: All paths are either relative to root (no leading slash) or absolute paths of the domain (leading slash)
*/
private func isExternal(file: String) -> Bool {
// Deconstruct file path
var path = ""
for part in file.components(separatedBy: "/") {
guard part != "" else {
continue
}
if path == "" {
path = part
} else {
path += "/" + part
}
if files.external[path] != nil {
return true
}
}
return false
}
func writeResults(to file: URL) {
guard !unreadableFiles.isEmpty || !unwritableFiles.isEmpty || !failedMinifications.isEmpty || !missingFiles.isEmpty || !copiedFiles.isEmpty || !minifiedFiles.isEmpty || !files.expected.isEmpty || !files.external.isEmpty else {
do {
if FileManager.default.fileExists(atPath: file.path) {
try FileManager.default.removeItem(at: file)
}
} catch {
print(" Failed to delete file log: \(error)")
}
return
}
var lines: [String] = []
func add<S>(_ name: String, _ property: S, convert: (S.Element) -> String) where S: Sequence {
let elements = property.map { " " + convert($0) }.sorted()
guard !elements.isEmpty else {
return
}
lines.append("\(name):")
lines.append(contentsOf: elements)
}
add("Unreadable files", unreadableFiles) { "\($0.key) (required by \($0.value.source)): \($0.value.message)" }
add("Unwritable files", unwritableFiles) { "\($0.key) (required by \($0.value.source)): \($0.value.message)" }
add("Failed minifications", failedMinifications) { "\($0.file) (required by \($0.source)): \($0.message)" }
add("Missing files", missingFiles) { "\($0.key) (required by \($0.value))" }
add("Copied files", copiedFiles) { $0 }
add("Minified files", minifiedFiles) { $0 }
add("Expected files", files.expected) { "\($0.key) (required by \($0.value))" }
add("External files", files.external) { "\($0.key) (required by \($0.value))" }
let data = lines.joined(separator: "\n").data(using: .utf8)!
do {
try data.createFolderAndWrite(to: file)
} catch {
print(" Failed to save file log: \(error)")
}
}
}

View File

@ -0,0 +1,492 @@
import Foundation
import CryptoKit
import AppKit
enum MinificationType {
case js
case css
}
typealias PageMap = [(language: String, pages: [String : String])]
final class GenerationResultsHandler {
/// The content folder where the input data is stored
let contentFolder: URL
/// The folder where the site is generated
let outputFolder: URL
private let fileUpdates: FileUpdateChecker
/**
All paths to page element folders, indexed by their unique id.
This relation is used to generate relative links to pages using the ``Element.id`
*/
private let pageMap: PageMap
private let configuration: Configuration
private var numberOfProcessedPages = 0
private let numberOfTotalPages: Int
init(in input: URL, to output: URL, configuration: Configuration, fileUpdates: FileUpdateChecker, pageMap: PageMap, pageCount: Int) {
self.contentFolder = input
self.fileUpdates = fileUpdates
self.outputFolder = output
self.pageMap = pageMap
self.configuration = configuration
self.numberOfTotalPages = pageCount
}
// MARK: Internal storage
/// Non-existent files. `key`: file path, `value`: source element
private var missingFiles: [String : String] = [:]
private(set) var files = FileData()
/// Files which could not be read (`key`: file path relative to content)
private var unreadableFiles: [String : (source: String, message: String)] = [:]
/// Files which could not be written (`key`: file path relative to output folder)
private var unwritableFiles: [String : (source: String, message: String)] = [:]
/// The paths to all files which were changed (relative to output)
private var generatedFiles: Set<String> = []
/// The referenced pages which do not exist (`key`: page id, `value`: source element path)
private var missingLinkedPages: [String : String] = [:]
/// All pages without content which have been created (`key`: page path, `value`: source element path)
private var emptyPages: [String : String] = [:]
/// All pages which have `status` set to ``PageState.draft``
private var draftPages: Set<String> = []
/// Generic warnings for pages
private var pageWarnings: [(message: String, source: String)] = []
/// A cache to get the size of source images, so that files don't have to be loaded multiple times (`key` absolute source path, `value`: image size)
private var imageSizeCache: [String : NSSize] = [:]
private(set) var images = ImageData()
// MARK: Generic warnings
private func warning(_ message: String, destination: String, path: String) {
let warning = " \(destination): \(message) required by \(path)"
images.warnings.append(warning)
}
func warning(_ message: String, source: String) {
pageWarnings.append((message, source))
}
// MARK: Page data
func getPagePath(for id: String, source: String, language: String) -> String? {
guard let pagePath = pageMap.first(where: { $0.language == language})?.pages[id] else {
missingLinkedPages[id] = source
return nil
}
return pagePath
}
private func markPageAsEmpty(page: String, source: String) {
emptyPages[page] = source
}
func markPageAsDraft(page: String) {
draftPages.insert(page)
}
// MARK: File actions
/**
Add a file as required, so that it will be copied to the output directory.
Special files may be minified.
- Parameter file: The file path, relative to the content directory
- Parameter source: The path of the source element requiring the file.
*/
func require(file: String, source: String) {
guard !isExternal(file: file) else {
return
}
let url = contentFolder.appendingPathComponent(file)
guard url.exists else {
markFileAsMissing(at: file, requiredBy: source)
return
}
guard url.isDirectory else {
markForCopyOrMinification(file: file, source: source)
return
}
do {
try FileManager.default
.contentsOfDirectory(atPath: url.path)
.forEach {
// Recurse into subfolders
require(file: file + "/" + $0, source: source)
}
} catch {
markFileAsUnreadable(at: file, requiredBy: source, message: "Failed to read folder: \(error)")
}
}
private func markFileAsMissing(at path: String, requiredBy source: String) {
missingFiles[path] = source
}
private func markFileAsUnreadable(at path: String, requiredBy source: String, message: String) {
unreadableFiles[path] = (source, message)
}
private func markFileAsUnwritable(at path: String, requiredBy source: String, message: String) {
unwritableFiles[path] = (source, message)
}
private func markFileAsGenerated(file: String) {
generatedFiles.insert(file)
}
private func markForCopyOrMinification(file: String, source: String) {
let ext = file.lastComponentAfter(".")
if configuration.minifyJavaScript, ext == "js" {
files.toMinify[file] = (source, .js)
return
}
if configuration.minifyCSS, ext == "css" {
files.toMinify[file] = (source, .css)
return
}
files.toCopy[file] = source
}
/**
Mark a file as explicitly missing (external).
This is done for the `externalFiles` entries in metadata,
to indicate that these files will be copied to the output folder manually.
*/
func exclude(file: String, source: String) {
files.external[file] = source
}
/**
Mark a file as expected to be present in the output folder after generation.
This is done for all links between pages, which only exist after the pages have been generated.
*/
func expect(file: String, source: String) {
files.expected[file] = source
}
/**
Check if a file is marked as external.
Also checks for sub-paths of the file, e.g if the folder `docs` is marked as external,
then files like `docs/index.html` are also found to be external.
- Note: All paths are either relative to root (no leading slash) or absolute paths of the domain (leading slash)
*/
private func isExternal(file: String) -> Bool {
// Deconstruct file path
var path = ""
for part in file.components(separatedBy: "/") {
guard part != "" else {
continue
}
if path == "" {
path = part
} else {
path += "/" + part
}
if files.external[path] != nil {
return true
}
}
return false
}
func getContentOfRequiredFile(at path: String, source: String) -> String? {
let url = contentFolder.appendingPathComponent(path)
guard url.exists else {
markFileAsMissing(at: path, requiredBy: source)
return nil
}
return getContentOfFile(at: url, path: path, source: source)
}
/**
Get the content of a file which may not exist.
*/
func getContentOfOptionalFile(at path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
let url = contentFolder.appendingPathComponent(path)
guard url.exists else {
if createEmptyFileIfMissing {
writeIfChanged(.init(), file: path, source: source)
}
return nil
}
return getContentOfFile(at: url, path: path, source: source)
}
func getContentOfMdFile(at path: String, source: String) -> String? {
guard let result = getContentOfOptionalFile(at: path, source: source, createEmptyFileIfMissing: configuration.createMdFilesIfMissing) else {
markPageAsEmpty(page: path, source: source)
return nil
}
return result
}
private func getContentOfFile(at url: URL, path: String, source: String) -> String? {
do {
return try String(contentsOf: url)
} catch {
markFileAsUnreadable(at: path, requiredBy: source, message: "\(error)")
return nil
}
}
@discardableResult
func writeIfChanged(_ data: Data, file: String, source: String) -> Bool {
let url = outputFolder.appendingPathComponent(file)
defer {
didCompletePage()
}
// Only write changed files
if url.exists, let oldContent = try? Data(contentsOf: url), data == oldContent {
return false
}
do {
try data.createFolderAndWrite(to: url)
markFileAsGenerated(file: file)
return true
} catch {
markFileAsUnwritable(at: file, requiredBy: source, message: "Failed to write file: \(error)")
return false
}
}
// MARK: Images
/**
Request the creation of an image.
- Returns: The final size of the image.
*/
@discardableResult
func requireSingleImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
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?) -> NSSize {
// Add @2x version
_ = requireScaledMultiImage(
source: source,
destination: destination.insert("@2x", beforeLast: "."),
requiredBy: path,
width: width * 2,
desiredHeight: desiredHeight.unwrapped { $0 * 2 })
// Add @1x version
return requireScaledMultiImage(source: source, destination: destination, requiredBy: path, width: width, desiredHeight: desiredHeight)
}
func requireFullSizeMultiVersionImage(source: String, destination: String, requiredBy path: String) -> NSSize {
requireMultiVersionImage(source: source, destination: destination, requiredBy: path, width: configuration.pageImageWidth, desiredHeight: nil)
}
func requireOriginalSizeImages(
source: String,
destination: String,
requiredBy path: String) {
_ = requireScaledMultiImage(
source: source,
destination: destination.insert("@full", beforeLast: "."),
requiredBy: path,
width: configuration.fullScreenImageWidth,
desiredHeight: nil)
}
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 = !outputFolder.appendingPathComponent(avifPath).exists || !outputFolder.appendingPathComponent(webpPath).exists
let size = requireImage(at: destination, generatedFrom: source, requiredBy: path, quality: 1.0, width: width, height: desiredHeight, alwaysGenerate: needsGeneration)
images.multiJobs[destination] = path
return size
}
private func markImageAsMissing(path: String, source: String) {
images.missing[path] = source
}
private func requireImage(at destination: String, generatedFrom source: String, requiredBy path: String, quality: Float, width: Int, height: Int?, alwaysGenerate: Bool) -> NSSize {
let height = height.unwrapped(CGFloat.init)
guard let imageSize = getImageSize(atPath: source, source: path) else {
// Image marked as missing here
return .zero
}
let scaledSize = imageSize.scaledDown(to: CGFloat(width))
// Check desired height, then we can forget about it
if let height = height {
let expectedHeight = scaledSize.width / CGFloat(width) * height
if abs(expectedHeight - scaledSize.height) > 2 {
warning("Expected a height of \(expectedHeight) (is \(scaledSize.height))", destination: destination, path: path)
}
}
let job = ImageJob(
destination: destination,
width: width,
path: path,
quality: quality,
alwaysGenerate: alwaysGenerate)
insert(job: job, source: source)
return scaledSize
}
func getImageSize(atPath path: String, source: String) -> NSSize? {
if let size = imageSizeCache[path] {
return size
}
let url = contentFolder.appendingPathComponent(path)
guard url.exists else {
markImageAsMissing(path: path, source: source)
return nil
}
guard let data = getImageData(at: url, path: path, source: source) else {
return nil
}
guard let image = NSImage(data: data) else {
images.unreadable[path] = source
return nil
}
let size = image.size
imageSizeCache[path] = size
return size
}
private func getImageData(at url: URL, path: String, source: String) -> Data? {
do {
let data = try Data(contentsOf: url)
fileUpdates.didLoad(data, at: path)
return data
} catch {
markFileAsUnreadable(at: path, requiredBy: source, message: "\(error)")
return nil
}
}
private func insert(job: ImageJob, source: String) {
guard let existingSource = images.jobs[source] else {
images.jobs[source] = [job]
return
}
guard let existingJob = existingSource.first(where: { $0.destination == job.destination }) else {
images.jobs[source] = existingSource + [job]
return
}
guard existingJob.width != job.width else {
return
}
warning("Existing job with width \(existingJob.width) (from \(existingJob.path)), but width \(job.width)", destination: job.destination, path: existingJob.path)
}
// MARK: Visual output
func didCompletePage() {
numberOfProcessedPages += 1
print(" Processed pages: \(numberOfProcessedPages)/\(numberOfTotalPages) \r", terminator: "")
fflush(stdout)
}
func printOverview() {
var notes: [String] = []
func addIfNotZero(_ count: Int, _ name: String) {
guard count > 0 else {
return
}
notes.append("\(count) \(name)")
}
func addIfNotZero<S>(_ sequence: Array<S>, _ name: String) {
addIfNotZero(sequence.count, name)
}
addIfNotZero(missingFiles.count, "missing files")
addIfNotZero(unreadableFiles.count, "unreadable files")
addIfNotZero(unwritableFiles.count, "unwritable files")
addIfNotZero(missingLinkedPages.count, "missing linked pages")
addIfNotZero(pageWarnings.count, "warnings")
print(" Updated pages: \(generatedFiles.count)/\(numberOfProcessedPages) ")
print(" Drafts: \(draftPages.count)")
print(" Empty pages: \(emptyPages.count)")
if !notes.isEmpty {
print(" Notes: " + notes.joined(separator: ", "))
}
print("")
}
func writeResults(to file: URL) {
guard !missingFiles.isEmpty || !unreadableFiles.isEmpty || !unwritableFiles.isEmpty || !missingLinkedPages.isEmpty || !pageWarnings.isEmpty || !generatedFiles.isEmpty || !draftPages.isEmpty || !emptyPages.isEmpty else {
do {
if FileManager.default.fileExists(atPath: file.path) {
try FileManager.default.removeItem(at: file)
}
} catch {
print(" Failed to delete generation log: \(error)")
}
return
}
var lines: [String] = []
func add<S>(_ name: String, _ property: S, convert: (S.Element) -> String) where S: Sequence {
let elements = property.map { " " + convert($0) }.sorted()
guard !elements.isEmpty else {
return
}
lines.append("\(name):")
lines.append(contentsOf: elements)
}
add("Missing files", missingFiles) { "\($0.key) (required by \($0.value))" }
add("Unreadable files", unreadableFiles) { "\($0.key) (required by \($0.value.source)): \($0.value.message)" }
add("Unwritable files", unwritableFiles) { "\($0.key) (required by \($0.value.source)): \($0.value.message)" }
add("Missing linked pages", missingLinkedPages) { "\($0.key) (linked by \($0.value))" }
add("Warnings", pageWarnings) { "\($0.source): \($0.message)" }
add("Generated files", generatedFiles) { $0 }
add("Drafts", draftPages) { $0 }
add("Empty pages", emptyPages) { "\($0.key) (from \($0.value))" }
let data = lines.joined(separator: "\n").data(using: .utf8)!
do {
try data.createFolderAndWrite(to: file)
} catch {
print(" Failed to save generation log: \(error)")
}
}
}

View File

@ -0,0 +1,19 @@
import Foundation
struct ImageData {
/// The images to generate (`key`: the image source path relative to the input folder)
var jobs: [String : [ImageJob]] = [:]
/// The images for which to generate multiple versions (`key`: the source file, `value`: the path of the requiring page)
var multiJobs: [String : String] = [:]
/// All warnings produced for images during generation
var warnings: [String] = []
/// The images which could not be found, but are required for the site (`key`: image path, `value`: source element path)
var missing: [String : String] = [:]
/// Images which could not be read (`key`: file path relative to content, `value`: source element path)
var unreadable: [String : String] = [:]
}

View File

@ -0,0 +1,348 @@
import Foundation
import AppKit
import CryptoKit
import Darwin.C
final class ImageGenerator {
private let imageOptimSupportedFileExtensions: Set<String> = ["jpg", "png", "svg"]
private let imageOptimizationBatchSize = 50
/**
The path to the input folder.
*/
private let input: URL
/**
The path to the output folder
*/
private let output: URL
private let imageReader: ImageReader
/**
All warnings produced for images during generation
*/
private var imageWarnings: [String]
/// The images which could not be found, but are required for the site (`key`: image path, `value`: source element path)
private var missingImages: [String : String]
/// Images which could not be read (`key`: file path relative to content, `value`: source element path)
private var unreadableImages: [String : String]
private var unhandledImages: [String: String] = [:]
/// All images modified or created during this generator run.
private var generatedImages: Set<String> = []
/// The images optimized by ImageOptim
private var optimizedImages: Set<String> = []
private var failedImages: [(path: String, message: String)] = []
private var numberOfGeneratedImages = 0
private let numberOfTotalImages: Int
private lazy var numberOfImagesToCreate = jobs.reduce(0) { $0 + $1.images.count } + multiJobs.count * 2
private var numberOfImagesToOptimize = 0
private var numberOfOptimizedImages = 0
private let images: ImageData
private lazy var jobs: [(source: String, images: [ImageJob])] = images.jobs
.sorted { $0.key < $1.key }
.map { (source: $0.key, images: $0.value) }
.filter {
// Only load image if required
let imageHasChanged = imageReader.imageHasChanged(at: $0.source)
return imageHasChanged || $0.images.contains { job in
job.alwaysGenerate || !output.appendingPathComponent(job.destination).exists
}
}
private lazy var multiJobs: [String : String] = {
let imagesToGenerate: Set<String> = jobs.reduce([]) { $0.union($1.images.map { $0.destination }) }
return images.multiJobs.filter { imagesToGenerate.contains($0.key) }
}()
init(input: URL, output: URL, reader: ImageReader, images: ImageData) {
self.input = input
self.output = output
self.imageReader = reader
self.images = images
self.numberOfTotalImages = images.jobs.reduce(0) { $0 + $1.value.count }
+ images.multiJobs.count * 2
self.imageWarnings = images.warnings
self.missingImages = images.missing
self.unreadableImages = images.unreadable
}
func generateImages() {
var notes: [String] = []
func addIfNotZero(_ count: Int, _ name: String) {
guard count > 0 else {
return
}
notes.append("\(count) \(name)")
}
print(" Changed sources: \(jobs.count)/\(images.jobs.count)")
print(" Total images: \(numberOfTotalImages) (\(numberOfTotalImages - imageReader.numberOfFilesAccessed) versions)")
for (source, jobs) in jobs {
create(images: jobs, from: source)
}
for (baseImage, source) in multiJobs {
createMultiImages(from: baseImage, path: source)
}
print(" Generated images: \(numberOfGeneratedImages)/\(numberOfImagesToCreate)")
optimizeImages()
print(" Optimized images: \(numberOfOptimizedImages)/\(numberOfImagesToOptimize)")
addIfNotZero(missingImages.count, "missing images")
addIfNotZero(unreadableImages.count, "unreadable images")
print(" Warnings: \(imageWarnings.count)")
if !notes.isEmpty {
print(" Notes: " + notes.joined(separator: ", "))
}
}
private func create(images: [ImageJob], from source: String) {
guard let image = imageReader.getImage(atPath: source) else {
unreadableImages[source] = images.first!.destination
didGenerateImage(count: images.count)
return
}
let jobs = imageReader.imageHasChanged(at: source) ? images : images.filter(isMissing)
// Update all images
jobs.forEach { job in
// Prevent memory overflow due to repeated NSImage operations
autoreleasepool {
create(job: job, from: image, source: source)
didGenerateImage()
}
}
}
private func isMissing(_ job: ImageJob) -> Bool {
job.alwaysGenerate || !output.appendingPathComponent(job.destination).exists
}
private func create(job: ImageJob, from image: NSImage, source: String) {
let destinationUrl = output.appendingPathComponent(job.destination)
create(job: job, from: image, source: source, at: destinationUrl)
}
private func create(job: ImageJob, from image: NSImage, source: String, at destinationUrl: URL) {
// Ensure that image file is supported
let ext = destinationUrl.pathExtension.lowercased()
guard ImageType(fileExtension: ext) != nil else {
fatalError()
}
let destinationExtension = destinationUrl.pathExtension.lowercased()
guard let type = ImageType(fileExtension: destinationExtension)?.fileType else {
unhandledImages[source] = job.destination
return
}
let desiredWidth = CGFloat(job.width)
let sourceRep = image.representations[0]
let destinationSize = NSSize(width: sourceRep.pixelsWide, height: sourceRep.pixelsHigh)
.scaledDown(to: desiredWidth)
// create NSBitmapRep manually, if using cgImage, the resulting size is wrong
let rep = NSBitmapImageRep(bitmapDataPlanes: nil,
pixelsWide: Int(destinationSize.width),
pixelsHigh: Int(destinationSize.height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: NSColorSpaceName.deviceRGB,
bytesPerRow: Int(destinationSize.width) * 4,
bitsPerPixel: 32)!
let ctx = NSGraphicsContext(bitmapImageRep: rep)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = ctx
image.draw(in: NSMakeRect(0, 0, destinationSize.width, destinationSize.height))
ctx?.flushGraphics()
NSGraphicsContext.restoreGraphicsState()
// Get NSData, and save it
guard let data = rep.representation(using: type, properties: [.compressionFactor: NSNumber(value: job.quality)]) else {
markImageAsFailed(source, error: "Failed to get data")
return
}
do {
try data.createFolderAndWrite(to: destinationUrl)
} catch {
markImageAsFailed(job.destination, error: "Failed to write image (\(error))")
return
}
generatedImages.insert(job.destination)
}
private func markImageAsFailed(_ source: String, error: String) {
failedImages.append((source, error))
}
private func createMultiImages(from source: String, path: String) {
guard generatedImages.contains(source) else {
didGenerateImage(count: 2)
return
}
let sourceUrl = output.appendingPathComponent(source)
let sourcePath = sourceUrl.path
guard sourceUrl.exists else {
missingImages[source] = path
didGenerateImage(count: 2)
return
}
let avifPath = source.dropAfterLast(".") + ".avif"
createAVIF(at: output.appendingPathComponent(avifPath).path, from: sourcePath)
generatedImages.insert(avifPath)
didGenerateImage()
let webpPath = source.dropAfterLast(".") + ".webp"
createWEBP(at: output.appendingPathComponent(webpPath).path, from: sourcePath)
generatedImages.insert(webpPath)
didGenerateImage()
compress(at: sourcePath)
}
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 {
let output = try safeShell(command)
if output == "" {
return
}
markImageAsFailed(destination, error: "Failed to create AVIF image: \(output)")
} catch {
markImageAsFailed(destination, error: "Failed to create AVIF image")
}
}
private func createWEBP(at destination: String, from source: String, quality: Int = 75) {
let command = "cwebp \(source) -q \(quality) -o \(destination)"
do {
let output = try safeShell(command)
if !output.contains("Error") {
return
}
markImageAsFailed(destination, error: "Failed to create WEBP image: \(output)")
} catch {
markImageAsFailed(destination, error: "Failed to create WEBP image: \(error)")
}
}
private func compress(at destination: String, quality: Int = 70) {
let command = "magick convert \(destination) -quality \(quality)% \(destination)"
do {
let output = try safeShell(command)
if output == "" {
return
}
markImageAsFailed(destination, error: "Failed to compress image: \(output)")
} catch {
markImageAsFailed(destination, error: "Failed to compress image: \(error)")
}
}
private func optimizeImages() {
let all = generatedImages
.filter { imageOptimSupportedFileExtensions.contains($0.lastComponentAfter(".")) }
.map { output.appendingPathComponent($0).path }
numberOfImagesToOptimize = all.count
for i in stride(from: 0, to: numberOfImagesToOptimize, by: imageOptimizationBatchSize) {
let endIndex = min(i+imageOptimizationBatchSize, numberOfImagesToOptimize)
let batch = all[i..<endIndex]
if optimizeImageBatch(batch) {
optimizedImages.formUnion(batch)
}
didOptimizeImage(count: batch.count)
}
}
private func optimizeImageBatch(_ batch: ArraySlice<String>) -> Bool {
let command = "imageoptim " + batch.joined(separator: " ")
do {
let output = try safeShell(command)
if output.contains("Finished") {
return true
}
for image in batch {
markImageAsFailed(image, error: "Failed to optimize image: \(output)")
}
return true
} catch {
for image in batch {
markImageAsFailed(image, error: "Failed to optimize image: \(error)")
}
return false
}
}
// MARK: Output
private func didGenerateImage(count: Int = 1) {
numberOfGeneratedImages += count
print(" Generated images: \(numberOfGeneratedImages)/\(numberOfImagesToCreate) \r", terminator: "")
fflush(stdout)
}
private func didOptimizeImage(count: Int) {
numberOfOptimizedImages += count
print(" Optimized images: \(numberOfOptimizedImages)/\(numberOfImagesToOptimize) \r", terminator: "")
fflush(stdout)
}
func writeResults(to file: URL) {
guard !missingImages.isEmpty || !unreadableImages.isEmpty || !failedImages.isEmpty || !unhandledImages.isEmpty || !imageWarnings.isEmpty || !generatedImages.isEmpty || !optimizedImages.isEmpty else {
do {
if FileManager.default.fileExists(atPath: file.path) {
try FileManager.default.removeItem(at: file)
}
} catch {
print(" Failed to delete image log: \(error)")
}
return
}
var lines: [String] = []
func add<S>(_ name: String, _ property: S, convert: (S.Element) -> String) where S: Sequence {
let elements = property.map { " " + convert($0) }.sorted()
guard !elements.isEmpty else {
return
}
lines.append("\(name):")
lines.append(contentsOf: elements)
}
add("Missing images", missingImages) { "\($0.key) (required by \($0.value))" }
add("Unreadable images", unreadableImages) { "\($0.key) (required by \($0.value))" }
add("Failed images", failedImages) { "\($0.path): \($0.message)" }
add("Unhandled images", unhandledImages) { "\($0.value) (from \($0.key))" }
add("Warnings", imageWarnings) { $0 }
add("Generated images", generatedImages) { $0 }
add("Optimized images", optimizedImages) { $0 }
let data = lines.joined(separator: "\n").data(using: .utf8)!
do {
try data.createFolderAndWrite(to: file)
} catch {
print(" Failed to save image log: \(error)")
}
}
}

View File

@ -0,0 +1,188 @@
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(" Pages found: \(numberOfMetadataFiles) \r", terminator: "")
}
func printMetadataScanOverview(languages: Int) {
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, "invalid properties")
addIfNotZero(unknownProperties, "unknown properties")
addIfNotZero(missingProperties, "missing properties")
print(" Pages found: \(numberOfMetadataFiles) ")
print(" Languages: \(languages)")
if !notes.isEmpty {
print(" Notes: " + notes.joined(separator: ", "))
}
}
func writeResults(to file: URL) {
guard !errors.isEmpty || !warnings.isEmpty || !unreadableMetadata.isEmpty || !unusedProperties.isEmpty || !invalidProperties.isEmpty || !unknownProperties.isEmpty || !missingProperties.isEmpty else {
do {
if FileManager.default.fileExists(atPath: file.path) {
try FileManager.default.removeItem(at: file)
}
} catch {
print(" Failed to delete metadata log: \(error)")
}
return
}
var lines: [String] = []
func add<S>(_ name: String, _ property: S, convert: (S.Element) -> String) where S: Sequence {
let elements = property.map { " " + convert($0) }.sorted()
guard !elements.isEmpty else {
return
}
lines.append("\(name):")
lines.append(contentsOf: elements)
}
add("Errors", errors) { "\($0.source): \($0.message)" }
add("Warnings", warnings) { "\($0.source): \($0.message)" }
add("Unreadable files", unreadableMetadata) { "\($0.source): \($0.error)" }
add("Unused properties", unusedProperties) { "\($0.source): \($0.name)" }
add("Invalid properties", invalidProperties) { "\($0.source): \($0.name) (\($0.reason))" }
add("Unknown properties", unknownProperties) { "\($0.source): \($0.name)" }
add("Missing properties", missingProperties) { "\($0.source): \($0.name)" }
let data = lines.joined(separator: "\n").data(using: .utf8)!
do {
try data.createFolderAndWrite(to: file)
} catch {
print(" Failed to save metadata log: \(error)")
}
}
}

View File

@ -0,0 +1,15 @@
import Foundation
enum HeaderFile: String {
case codeHightlighting = "highlight.js"
case modelViewer = "model-viewer.js"
var asModule: Bool {
switch self {
case .codeHightlighting: return false
case .modelViewer: return true
}
}
}
typealias RequiredHeaders = Set<HeaderFile>

View File

@ -0,0 +1,20 @@
import Foundation
@discardableResult
func safeShell(_ command: String) throws -> String {
let task = Process()
let pipe = Pipe()
task.standardOutput = pipe
task.standardError = pipe
task.arguments = ["-cl", command]
task.executableURL = URL(fileURLWithPath: "/bin/zsh")
task.standardInput = nil
try task.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = String(data: data, encoding: .utf8)!
return output
}

View File

@ -10,4 +10,6 @@ struct BackNavigationTemplate: Template {
static let templateName = "back.html" static let templateName = "back.html"
let raw: String let raw: String
let results: GenerationResultsHandler
} }

View File

@ -10,4 +10,6 @@ struct BoxTemplate: Template {
static let templateName = "box.html" static let templateName = "box.html"
var raw: String var raw: String
let results: GenerationResultsHandler
} }

View File

@ -9,4 +9,6 @@ struct OverviewSectionCleanTemplate: Template {
static let templateName = "overview-section-clean.html" static let templateName = "overview-section-clean.html"
let raw: String let raw: String
let results: GenerationResultsHandler
} }

View File

@ -12,4 +12,6 @@ struct OverviewSectionTemplate: Template {
static let templateName = "overview-section.html" static let templateName = "overview-section.html"
let raw: String let raw: String
let results: GenerationResultsHandler
} }

View File

@ -12,5 +12,7 @@ struct PageHeadTemplate: Template {
let raw: String let raw: String
let results: GenerationResultsHandler
static let templateName = "head.html" static let templateName = "head.html"
} }

View File

@ -1,18 +1,36 @@
import Foundation import Foundation
struct PageImageTemplate: Template { enum PageImageTemplateKey: String, CaseIterable {
case altText = "ALT_TEXT"
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"
case rightText = "RIGHT_TEXT" case rightText = "RIGHT_TEXT"
case number = "NUMBER"
} }
struct EnlargeableImageTemplate: Template {
typealias Key = PageImageTemplateKey
static let templateName = "image-enlargeable.html"
let raw: String
let results: GenerationResultsHandler
}
struct PageImageTemplate: Template {
typealias Key = PageImageTemplateKey
static let templateName = "image.html" static let templateName = "image.html"
let raw: String let raw: String
let results: GenerationResultsHandler
} }

View File

@ -3,9 +3,9 @@ import Foundation
struct PageLinkTemplate: Template { struct PageLinkTemplate: Template {
enum Key: String, CaseIterable { enum Key: String, CaseIterable {
case altText = "ALT_TEXT"
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"
@ -16,6 +16,8 @@ struct PageLinkTemplate: Template {
let raw: String let raw: String
let results: GenerationResultsHandler
func makePath(components: [String]) -> String { func makePath(components: [String]) -> String {
components.joined(separator: " » ") // &ensp;&raquo;&ensp;") components.joined(separator: " » ") // &ensp;&raquo;&ensp;")
} }

View File

@ -23,6 +23,8 @@ struct PageVideoTemplate: Template {
let raw: String let raw: String
let results: GenerationResultsHandler
func generate<T>(sources: [VideoSource], options: T) -> String where T: Sequence, T.Element == VideoOption { func generate<T>(sources: [VideoSource], options: T) -> String where T: Sequence, T.Element == VideoOption {
let sourcesCode = sources.map(makeSource).joined(separator: "\n") let sourcesCode = sources.map(makeSource).joined(separator: "\n")
let optionCode = options.map { $0.rawValue }.joined(separator: " ") let optionCode = options.map { $0.rawValue }.joined(separator: " ")

View File

@ -0,0 +1,23 @@
import Foundation
struct SlideshowImageTemplate: Template {
enum Key: String, CaseIterable {
case altText = "ALT_TEXT"
case url = "URL"
case image = "IMAGE"
case title = "TITLE"
case subtitle = "SUBTITLE"
case number = "NUMBER"
}
static let templateName = "slideshow-image.html"
let raw: String
let results: GenerationResultsHandler
func makePath(components: [String]) -> String {
components.joined(separator: " » ") // &ensp;&raquo;&ensp;")
}
}

View File

@ -0,0 +1,15 @@
import Foundation
struct SlideshowTemplate: Template {
enum Key: String, CaseIterable {
case title = "TITLE"
case content = "CONTENT"
}
static let templateName = "slideshow.html"
let raw: String
let results: GenerationResultsHandler
}

View File

@ -0,0 +1,14 @@
import Foundation
struct SlideshowsTemplate: Template {
enum Key: String, CaseIterable {
case content = "CONTENT"
}
static let templateName = "slideshows.html"
let raw: String
let results: GenerationResultsHandler
}

View File

@ -6,9 +6,9 @@ protocol ThumbnailTemplate {
} }
enum ThumbnailKey: String, CaseIterable { enum ThumbnailKey: String, CaseIterable {
case altText = "ALT_TEXT"
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"
} }
@ -21,6 +21,8 @@ struct LargeThumbnailTemplate: Template, ThumbnailTemplate {
let raw: String let raw: String
let results: GenerationResultsHandler
func makeCorner(text: String) -> String { func makeCorner(text: String) -> String {
"<span class=\"corner\"><span>\(text)</span></span>" "<span class=\"corner\"><span>\(text)</span></span>"
} }
@ -37,6 +39,8 @@ struct SquareThumbnailTemplate: Template, ThumbnailTemplate {
static let templateName = "thumbnail-square.html" static let templateName = "thumbnail-square.html"
let raw: String let raw: String
let results: GenerationResultsHandler
} }
struct SmallThumbnailTemplate: Template, ThumbnailTemplate { struct SmallThumbnailTemplate: Template, ThumbnailTemplate {
@ -46,5 +50,7 @@ struct SmallThumbnailTemplate: Template, ThumbnailTemplate {
static let templateName = "thumbnail-small.html" static let templateName = "thumbnail-small.html"
let raw: String let raw: String
let results: GenerationResultsHandler
} }

View File

@ -12,4 +12,6 @@ struct TopBarTemplate: Template {
static let templateName = "bar.html" static let templateName = "bar.html"
var raw: String var raw: String
let results: GenerationResultsHandler
} }

View File

@ -35,7 +35,7 @@ struct LocalizedSiteTemplate {
factory.page factory.page
} }
init(factory: TemplateFactory, language: String, site: Element) { init(factory: TemplateFactory, language: String, site: Element, results: GenerationResultsHandler) {
self.author = site.author self.author = site.author
self.factory = factory self.factory = factory
@ -51,7 +51,7 @@ struct LocalizedSiteTemplate {
self.month = df2 self.month = df2
let df3 = DateFormatter() let df3 = DateFormatter()
df3.dateFormat = "dd" df3.dateFormat = "d"
df3.locale = Locale(identifier: language) df3.locale = Locale(identifier: language)
self.day = df3 self.day = df3
@ -66,10 +66,8 @@ struct LocalizedSiteTemplate {
language: language, language: language,
sections: sections, sections: sections,
topBarWebsiteTitle: site.topBarTitle) topBarWebsiteTitle: site.topBarTitle)
self.pageHead = PageHeadGenerator( self.pageHead = PageHeadGenerator(factory: factory, results: results)
factory: factory) self.overviewSection = OverviewSectionGenerator(factory: factory, siteRoot: site, results: results)
self.overviewSection = OverviewSectionGenerator(
factory: factory)
} }
// MARK: Content // MARK: Content

View File

@ -19,6 +19,8 @@ struct CenteredHeaderTemplate: Template {
let raw: String let raw: String
let results: GenerationResultsHandler
static let templateName = "header-center.html" static let templateName = "header-center.html"
} }
@ -28,5 +30,7 @@ struct LeftHeaderTemplate: Template {
let raw: String let raw: String
let results: GenerationResultsHandler
static let templateName = "header-left.html" static let templateName = "header-left.html"
} }

View File

@ -3,6 +3,7 @@ import Foundation
struct PageTemplate: Template { struct PageTemplate: Template {
enum Key: String, CaseIterable { enum Key: String, CaseIterable {
case language = "LANG"
case head = "HEAD" case head = "HEAD"
case topBar = "TOP_BAR" case topBar = "TOP_BAR"
case contentClass = "CONTENT_CLASS" case contentClass = "CONTENT_CLASS"
@ -18,4 +19,6 @@ struct PageTemplate: Template {
static let templateName = "page.html" static let templateName = "page.html"
let raw: String let raw: String
let results: GenerationResultsHandler
} }

View File

@ -8,25 +8,33 @@ protocol Template {
var raw: String { get } var raw: String { get }
init(raw: String) var results: GenerationResultsHandler { get }
init(raw: String, results: GenerationResultsHandler)
} }
extension Template { extension Template {
init(in folder: URL) throws { init(in folder: URL, results: GenerationResultsHandler) throws {
let url = folder.appendingPathComponent(Self.templateName) let url = folder.appendingPathComponent(Self.templateName)
try self.init(from: url) try self.init(from: url, results: results)
} }
init(from url: URL) throws { init(from url: URL, results: GenerationResultsHandler) throws {
do {
let raw = try String(contentsOf: url) let raw = try String(contentsOf: url)
self.init(raw: raw) self.init(raw: raw, results: results)
} catch {
results.warning("Failed to load: \(error)", source: "Template \(url.lastPathComponent)")
throw error
}
} }
func generate(_ content: [Key : String], to url: URL) -> Bool { @discardableResult
let content = generate(content) func generate(_ content: [Key : String], to file: String, source: String) -> Bool {
return files.write(content, to: url) let content = generate(content).data(using: .utf8)!
return results.writeIfChanged(content, file: file, source: source)
} }
func generate(_ content: [Key : String], shouldIndent: Bool = false) -> String { func generate(_ content: [Key : String], shouldIndent: Bool = false) -> String {

View File

@ -51,31 +51,48 @@ final class TemplateFactory {
let image: PageImageTemplate let image: PageImageTemplate
let largeImage: EnlargeableImageTemplate
let video: PageVideoTemplate let video: PageVideoTemplate
// MARK: Slideshow
let slideshows: SlideshowsTemplate
let slideshow: SlideshowTemplate
let slideshowImage: SlideshowImageTemplate
// MARK: HTML // MARK: HTML
let html: HTMLElementsGenerator let html: HTMLElementsGenerator
// MARK: Init // MARK: Init
init(templateFolder: URL) throws { init(templateFolder: URL, results: GenerationResultsHandler) throws {
self.templateFolder = templateFolder self.templateFolder = templateFolder
self.backNavigation = try .init(in: templateFolder) func create<T>() throws -> T where T: Template {
self.pageHead = try .init(in: templateFolder) try .init(in: templateFolder, results: results)
self.topBar = try .init(in: templateFolder) }
self.overviewSection = try .init(in: templateFolder) self.backNavigation = try create()
self.overviewSectionClean = try .init(in: templateFolder) self.pageHead = try create()
self.box = try .init(in: templateFolder) self.topBar = try create()
self.pageLink = try .init(in: templateFolder) self.overviewSection = try create()
self.largeThumbnail = try .init(in: templateFolder) self.overviewSectionClean = try create()
self.squareThumbnail = try .init(in: templateFolder) self.box = try create()
self.smallThumbnail = try .init(in: templateFolder) self.pageLink = try create()
self.leftHeader = try .init(in: templateFolder) self.largeThumbnail = try create()
self.centeredHeader = try .init(in: templateFolder) self.squareThumbnail = try create()
self.page = try .init(in: templateFolder) self.smallThumbnail = try create()
self.image = try .init(in: templateFolder) self.leftHeader = try create()
self.video = try .init(in: templateFolder) self.centeredHeader = try create()
self.page = try create()
self.image = try create()
self.largeImage = try create()
self.video = try create()
self.slideshow = try create()
self.slideshows = try create()
self.slideshowImage = try create()
self.html = .init() self.html = .init()
} }

View File

@ -1,10 +1,6 @@
import Foundation import Foundation
import ArgumentParser import ArgumentParser
var configuration: Configuration!
let log = ValidationLog()
var files: FileSystem!
var siteRoot: Element!
@main @main
struct CHGenerator: ParsableCommand { struct CHGenerator: ParsableCommand {
@ -17,28 +13,175 @@ struct CHGenerator: ParsableCommand {
} }
} }
private func generate(configPath: String) throws { private func loadConfiguration(at configPath: String) -> Configuration? {
print("--- CONFIGURATION ----------------------------------")
print(" ")
print(" Configuration file: \(configPath)")
let configUrl = URL(fileURLWithPath: configPath) let configUrl = URL(fileURLWithPath: configPath)
guard configUrl.exists else {
print(" Error: Configuration file not found")
return nil
}
var 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)
config.adjustPathsRelative(to: configUrl.deletingLastPathComponent())
} catch {
print(" Configuration error: \(error)")
return nil
}
config.printOverview()
print(" ")
return config
}
files = .init( private func loadSiteData(in folder: URL, runFolder: URL) throws -> (root: Element, pageMap: PageMap)? {
print("--- SOURCE FILES -----------------------------------")
print(" ")
let log = MetadataInfoLogger(input: folder)
let root = Element(atRoot: folder, log: log)
let file = runFolder.appendingPathComponent("metadata.txt")
defer {
log.writeResults(to: file)
print(" ")
}
guard let root else {
log.printMetadataScanOverview(languages: 0)
print(" Error: No site root loaded, aborting generation")
return nil
}
let pageMap = root.languages.map { language in
(language: language.language,
pages: root.getExternalPageMap(language: language.language, log: log))
}
log.printMetadataScanOverview(languages: root.languages.count)
return (root, pageMap)
}
private func generatePages(from root: Element, configuration: Configuration, fileUpdates: FileUpdateChecker, pageMap: PageMap, runFolder: URL) -> (ImageData, FileData)? {
print("--- GENERATION -------------------------------------")
print(" ")
let pageCount = pageMap.reduce(0) { $0 + $1.pages.count }
let results = GenerationResultsHandler(
in: configuration.contentDirectory, in: configuration.contentDirectory,
to: configuration.outputDirectory) to: configuration.outputDirectory,
configuration: configuration,
fileUpdates: fileUpdates,
pageMap: pageMap,
pageCount: pageCount)
siteRoot = Element(atRoot: configuration.contentDirectory) defer { results.printOverview() }
guard siteRoot != nil else {
let siteGenerator: SiteGenerator
do {
siteGenerator = try SiteGenerator(results: results)
} catch {
return nil
}
siteGenerator.generate(site: root)
let url = runFolder.appendingPathComponent("pages.txt")
results.writeResults(to: url)
if let error = fileUpdates.writeDetectedFileChanges(to: runFolder) {
print(" Hashes not saved: \(error)")
}
return (results.images, results.files)
}
private func generateImages(_ images: ImageData, configuration: Configuration, runFolder: URL, fileUpdates: FileUpdateChecker) {
print("--- IMAGES -----------------------------------------")
print(" ")
let reader = ImageReader(in: configuration.contentDirectory, runFolder: runFolder, fileUpdates: fileUpdates)
let generator = ImageGenerator(
input: configuration.contentDirectory,
output: configuration.outputDirectory,
reader: reader, images: images)
generator.generateImages()
print(" ")
let file = runFolder.appendingPathComponent("images.txt")
generator.writeResults(to: file)
}
private func copyFiles(files: FileData, configuration: Configuration, runFolder: URL) {
print("--- FILES ------------------------------------------")
print(" ")
let generator = FileGenerator(
input: configuration.contentDirectory,
output: configuration.outputDirectory,
runFolder: runFolder,
files: files)
generator.generate()
let file = runFolder.appendingPathComponent("files.txt")
generator.writeResults(to: file)
}
private func finish(start: Date, complete: Bool) {
print("--- SUMMARY ----------------------------------------")
print(" ")
let duration = Int(-start.timeIntervalSinceNow.rounded())
if duration < 3600 {
print(String(format: " Duration: %d:%02d", duration / 60, duration % 60))
} else {
print(String(format: " Duration: %d:%02d:%02d", duration / 3600, (duration / 60) % 60, duration % 60))
}
print(" Complete: \(complete ? "Yes" : "No")")
print(" ")
print("----------------------------------------------------")
}
private func generate(configPath: String) throws {
let start = Date()
var complete = false
defer {
// 6. Print summary
finish(start: start, complete: complete)
}
print(" ")
guard checkDependencies() else {
return return
} }
let siteGenerator = try SiteGenerator()
siteGenerator.generate(site: siteRoot)
files.printGeneratedPages() // 1. Load configuration
files.printEmptyPages() guard let configuration = loadConfiguration(at: configPath) else {
files.printDraftPages() return
}
files.createImages()
files.copyRequiredFiles() let runFolder = configuration.contentDirectory.appendingPathComponent("run")
files.printExternalFiles()
files.writeDetectedFileChangesToDisk() // 2. Scan site elements
guard let (siteRoot, pageMap) = try loadSiteData(in: configuration.contentDirectory, runFolder: runFolder) else {
return
}
let fileUpdates = FileUpdateChecker(input: configuration.contentDirectory)
switch fileUpdates.loadPreviousRun(from: runFolder) {
case .notLoaded:
print("Regarding all files as new (no hashes loaded)")
case .loaded:
break
case .failed(let error):
print("Regarding all files as new (\(error))")
}
// 3. Generate pages
guard let (images, files) = generatePages(from: siteRoot, configuration: configuration, fileUpdates: fileUpdates, pageMap: pageMap, runFolder: runFolder) else {
return
}
// 4. Generate images
generateImages(images, configuration: configuration, runFolder: runFolder, fileUpdates: fileUpdates)
// 5. Copy/minify files
copyFiles(files: files, configuration: configuration, runFolder: runFolder)
complete = true
} }

View File

@ -1,6 +1,8 @@
{ {
"pageImageWidth" : 748, "pageImageWidth" : 748,
"minifyCSSandJS" : true, "fullScreenImageWidth" : 4000,
"minifyJavaScript" : false,
"minifyCSS" : false,
"createMdFilesIfMissing" : false, "createMdFilesIfMissing" : false,
"contentPath" : "/path/to/content/folder", "contentPath" : "/path/to/content/folder",
"outputPath" : "/path/to/output/folder") "outputPath" : "/path/to/output/folder")

View File

@ -1,13 +1,19 @@
#!/bin/bash #!/bin/bash
# Install the required dependencies for CHGenerator # Install the required dependencies for CHGenerator
# Note: The following is only required if `minifyCSSandJS=True` # Install magick to resize images
# is set in the generator configuration brew install imagemagick
# Install the Javascript minifier # Install avif to create AVIF versions of images
npm install avif -g
# Install the Javascript minifier (required if `minifyJavaScript = True`)
# https://github.com/mishoo/UglifyJS # https://github.com/mishoo/UglifyJS
npm install uglify-js -g npm install uglify-js -g
# Install the clean-css minifier # Install the clean-css minifier (required if `minifyCSS = True`)
# 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