Compare commits

..

49 Commits

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

1
.gitignore vendored
View File

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

View File

@@ -140,6 +140,18 @@ extension Element {
This property is mandatory at root level, and is propagated to child elements.
*/
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?
}
}
@@ -171,6 +183,8 @@ extension Element.LocalizedMetadata {
self.relatedContentText = log.required(data.relatedContentText, name: "relatedContentText", source: source, &isValid)
self.navigationTextAsNextPage = log.required(data.navigationTextAsNextPage, name: "navigationTextAsNextPage", source: source, &isValid)
self.navigationTextAsPreviousPage = log.required(data.navigationTextAsPreviousPage, name: "navigationTextAsPreviousPage", source: source, &isValid)
self.mostRecentTitle = data.mostRecentTitle
self.featuredTitle = data.featuredTitle
guard isValid else {
return nil
@@ -202,6 +216,8 @@ extension Element.LocalizedMetadata {
self.relatedContentText = data.relatedContentText ?? parent.relatedContentText
self.navigationTextAsPreviousPage = data.navigationTextAsPreviousPage ?? parent.navigationTextAsPreviousPage
self.navigationTextAsNextPage = data.navigationTextAsNextPage ?? parent.navigationTextAsNextPage
self.mostRecentTitle = data.mostRecentTitle ?? parent.mostRecentTitle
self.featuredTitle = data.featuredTitle ?? parent.featuredTitle
guard isValid else {
return nil

View File

@@ -65,7 +65,7 @@ struct Element {
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.
- Note: This property defaults to an empty set.
@@ -132,6 +132,13 @@ struct Element {
*/
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.
*/
@@ -183,13 +190,14 @@ struct Element {
self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source)
self.externalFiles = metadata.externalFiles ?? []
self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: "") } ?? []
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: "", log: log) } ?? []
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
self.thumbnailStyle = log.castUnused(metadata.thumbnailStyle, "thumbnailStyle", source: source)
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source)
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
self.headerType = log.cast(metadata.headerType, "headerType", 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
.init(atRoot: folder, data: language, log: log)
@@ -204,7 +212,7 @@ struct Element {
return nil
}
files.add(page: path, id: id)
//files.add(page: path, id: id)
self.readElements(in: folder, source: nil, log: log)
}
@@ -232,6 +240,10 @@ struct Element {
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
}
var isValid = true
@@ -239,19 +251,20 @@ struct Element {
self.author = metadata.author ?? parent.author
self.topBarTitle = log.unused(metadata.topBarTitle, "topBarTitle", source: source)
self.date = metadata.date.unwrapped { log.cast($0, "date", source: source) }
self.endDate = metadata.date.unwrapped { log.cast($0, "endDate", source: source) }
self.state = log.cast(metadata.state, "state", source: source)
self.endDate = metadata.endDate.unwrapped { log.cast($0, "endDate", source: source) }
self.state = state
self.sortIndex = metadata.sortIndex
// TODO: Propagate external files from the parent if subpath matches?
self.externalFiles = Element.rootPaths(for: metadata.externalFiles, path: path)
self.requiredFiles = Element.rootPaths(for: metadata.requiredFiles, path: path)
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: path) } ?? []
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: path, log: log) } ?? []
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
self.thumbnailStyle = log.cast(metadata.thumbnailStyle, "thumbnailStyle", source: source)
self.useManualSorting = metadata.useManualSorting ?? false
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
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
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
log.warning("Language '\(parentData.language)' not found", source: source)
@@ -285,8 +298,55 @@ struct Element {
return nil
}
files.add(page: path, id: id)
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
}
}
@@ -313,7 +373,7 @@ extension Element {
}
var hasNestingElements: Bool {
elements.contains { $0.containsElements }
elements.contains { $0.hasVisibleChildren }
}
func itemsForOverview(_ count: Int? = nil) -> [Element] {
@@ -325,12 +385,15 @@ 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.date != nil }
.filter { $0.thumbnailStyle == .large && $0.state == .standard && $0.date != nil }
.sorted { $0.date! > $1.date! }
return Array(all.prefix(count))
}
@@ -369,6 +432,9 @@ extension Element {
- Returns: The relative url from a localized page of the element to the target file.
*/
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
// i.e. travel/alps instead of travel/alps/en.html
let ownParts = path.components(separatedBy: "/")
@@ -376,7 +442,7 @@ extension Element {
// Find the common elements of the path, which can be discarded
var index = 0
while pageParts[index] == ownParts[index] {
while index < pageParts.count && index < ownParts.count && pageParts[index] == ownParts[index] {
index += 1
}
// The relative path needs to go down to the first common folder,
@@ -566,21 +632,14 @@ extension Element {
extension Element {
private var additionalHeadContentPath: String {
var additionalHeadContentPath: String {
path + "/head.html"
}
func customHeadContent() -> String? {
files.contentOfOptionalFile(atPath: additionalHeadContentPath, source: path)
}
private var additionalFooterContentPath: String {
var additionalFooterContentPath: String {
path + "/footer.html"
}
func customFooterContent() -> String? {
files.contentOfOptionalFile(atPath: additionalFooterContentPath, source: path)
}
}
// MARK: Debug
@@ -607,14 +666,14 @@ extension Element {
let desiredHeight: Int?
init?(input: String, path: String) {
init?(input: String, path: String, log: MetadataInfoLogger) {
let parts = input.components(separatedBy: " ").filter { !$0.isEmpty }
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
}
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
}
@@ -626,7 +685,7 @@ extension Element {
return
}
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
}
self.desiredHeight = height
@@ -716,10 +775,6 @@ extension Element {
let destination = pathRelativeToRootForContainedInputFile("thumbnail-\(language).\(ext)")
return (source, destination)
}
let thumbnailUrl = inputFolder.appendingPathComponent(thumbnailPath)
if !thumbnailUrl.exists {
log.add(error: "Missing thumbnail", source: path)
}
let source = pathRelativeToRootForContainedInputFile(thumbnailPath)
let ext = thumbnailPath.lastComponentAfter(".")
let destination = pathRelativeToRootForContainedInputFile("thumbnail.\(ext)")

View File

@@ -133,6 +133,18 @@ extension GenericMetadata {
This property is mandatory at root level, and is propagated to child elements.
*/
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,
.navigationTextAsPreviousPage,
.navigationTextAsNextPage,
.mostRecentTitle,
.featuredTitle,
]
}
@@ -190,7 +204,9 @@ extension GenericMetadata.LocalizedMetadata {
externalUrl: nil,
relatedContentText: nil,
navigationTextAsPreviousPage: nil,
navigationTextAsNextPage: nil)
navigationTextAsNextPage: nil,
mostRecentTitle: nil,
featuredTitle: nil)
}
/**
@@ -214,7 +230,9 @@ extension GenericMetadata.LocalizedMetadata {
externalUrl: nil,
relatedContentText: "",
navigationTextAsPreviousPage: "",
navigationTextAsNextPage: "")
navigationTextAsNextPage: "",
mostRecentTitle: nil,
featuredTitle: nil)
}
static var full: GenericMetadata.LocalizedMetadata {
@@ -235,6 +253,8 @@ extension GenericMetadata.LocalizedMetadata {
externalUrl: "",
relatedContentText: "",
navigationTextAsPreviousPage: "",
navigationTextAsNextPage: "")
navigationTextAsNextPage: "",
mostRecentTitle: "",
featuredTitle: "")
}
}

View File

@@ -131,6 +131,13 @@ struct GenericMetadata {
*/
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.
*/
@@ -157,6 +164,7 @@ extension GenericMetadata: Codable {
.overviewItemCount,
.headerType,
.showMostRecentSection,
.featuredPages,
.languages,
]
}
@@ -229,6 +237,7 @@ extension GenericMetadata {
overviewItemCount: 6,
headerType: "left",
showMostRecentSection: false,
featuredPages: [],
languages: [.full])
}
}

View File

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

View File

@@ -56,6 +56,11 @@ extension String {
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 {
components(separatedBy: separator).first!
}
@@ -67,10 +72,25 @@ extension String {
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 {
components(separatedBy: start).last!
.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 {

View File

@@ -50,4 +50,23 @@ extension URL {
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
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
/**
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.
- 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.
*/
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.
*/
let contentPath: String
var contentPath: String
/**
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.
@@ -57,12 +70,22 @@ struct Configuration: Codable {
.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(" Minify JavaScript: \(minifyCSSandJS)")
print(" Minify CSS: \(minifyCSSandJS)")
print(" Full-screen width: \(fullScreenImageWidth)")
print(" Minify JavaScript: \(minifyJavaScript)")
print(" Minify CSS: \(minifyCSS)")
print(" Create markdown files: \(createMdFilesIfMissing)")
}
}

View File

@@ -1,443 +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 let configuration: Configuration
private var tempFile: URL {
input.appendingPathComponent(FileSystem.tempFileName)
}
let generatorInfoFolder: URL
/**
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, configuration: Configuration) {
self.input = input
self.output = output
self.images = .init(input: input, output: output)
self.generatorInfoFolder = input.appendingPathComponent("run")
self.configuration = configuration
}
func urlInOutputFolder(_ path: String) -> URL {
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 contentOfMdFile(atPath path: String, source: String) -> String? {
contentOfOptionalFile(atPath: path, source: source, createEmptyFileIfMissing: configuration.createMdFilesIfMissing)
}
func contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
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 requireSingleImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
images.requireImage(
at: destination,
generatedFrom: source,
requiredBy: path,
quality: 0.7,
width: width,
height: desiredHeight,
alwaysGenerate: false)
}
/**
Create images of different types.
This function generates versions for the given image, including png/jpg, avif, and webp. Different pixel density versions (1x and 2x) are also generated.
- Parameter destination: The path to the destination file
*/
@discardableResult
func requireMultiVersionImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
images.requireMultiVersionImage(source: source, destination: destination, requiredBy: path, width: width, desiredHeight: desiredHeight)
}
func requireFullSizeMultiVersionImage(source: String, destination: String, requiredBy path: String) -> NSSize {
images.requireMultiVersionImage(source: source, destination: destination, requiredBy: path, width: configuration.pageImageWidth, desiredHeight: nil)
}
func createImages() {
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 FileSystem.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 FileSystem.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
static 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,13 +3,9 @@ import CryptoKit
final class FileUpdateChecker {
private static let hashesFileName = "hashes.json"
private let hashesFileName = "hashes.json"
private let input: URL
private var hashesFile: URL {
input.appendingPathComponent(FileUpdateChecker.hashesFileName)
}
/**
The hashes of all accessed files from the previous run
@@ -25,35 +21,41 @@ final class FileUpdateChecker {
*/
private var accessedFiles: [String : Data] = [:]
private var source: String {
"FileUpdateChecker"
var numberOfFilesLoaded: Int {
previousFiles.count
}
var numberOfFilesAccessed: Int {
accessedFiles.count
}
init(input: URL) {
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
do {
data = try Data(contentsOf: hashesFile)
data = try Data(contentsOf: url)
} catch {
log.add(
warning: "File hashes could not be read, regarding all content as new",
source: source,
error: error)
return
return .failed("Failed to read hashes from last run: \(error)")
}
do {
self.previousFiles = try JSONDecoder().decode(from: data)
} catch {
log.add(
warning: "File hashes could not be decoded, regarding all content as new",
source: source,
error: error)
return
return .failed("Failed to decode hashes from last run: \(error)")
}
return .loaded
}
func fileHasChanged(at path: String) -> Bool {
@@ -73,16 +75,18 @@ final class FileUpdateChecker {
accessedFiles[path] = SHA256.hash(data: data).data
}
func writeDetectedFileChangesToDisk() {
func writeDetectedFileChanges(to folder: URL) -> String? {
let url = folder.appendingPathComponent(hashesFileName)
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(accessedFiles)
try data.write(to: hashesFile)
try data.write(to: url)
return nil
} 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,456 +0,0 @@
import Foundation
import AppKit
import CryptoKit
import Darwin.C
private struct ImageJob {
let destination: String
let width: Int
let path: String
let quality: Float
let alwaysGenerate: Bool
}
final class ImageGenerator {
private let imageOptimSupportedFileExtensions: Set<String> = ["jpg", "png", "svg"]
private let imageOptimizationBatchSize = 50
/**
The path to the input folder.
*/
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 for which to generate multiple versions
The key is the source file, the value is the path of the requiring page.
*/
private var multiImageJobs: [String : String] = [:]
/**
The images which could not be found, but are required for the site.
The 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> = []
/**
The images optimized by ImageOptim
*/
private var optimizedImages: Set<String> = []
/**
A cache to get the size of source images, so that files don't have to be loaded multiple times.
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, quality: Float, width: Int, height: Int?, alwaysGenerate: Bool) -> 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,
quality: quality,
alwaysGenerate: alwaysGenerate)
insert(job: job, source: source)
return scaledSize
}
private func insert(job: ImageJob, source: String) {
guard let existingSource = imageJobs[source] else {
imageJobs[source] = [job]
return
}
guard let existingJob = existingSource.first(where: { $0.destination == job.destination }) else {
imageJobs[source] = existingSource + [job]
return
}
if existingJob.width != job.width {
addWarning("Multiple image widths (\(existingJob.width) and \(job.width))", destination: job.destination, path: "\(existingJob.path) and \(job.path)")
}
}
func createImages() {
var count = 0
for (source, jobs) in imageJobs.sorted(by: { $0.key < $1.key }) {
print(String(format: "Creating images: %4d / %d\r", count, imageJobs.count), terminator: "")
fflush(stdout)
create(images: jobs, from: source)
count += 1
}
print(" \r", terminator: "")
createMultiImages()
optimizeImages()
printMissingImages()
printImageWarnings()
printGeneratedImages()
printTotalImageCount()
}
private func printMissingImages() {
guard !missingImages.isEmpty else {
return
}
print("\(missingImages.count) missing images:")
let sort = missingImages.sorted { (a, b) in
a.value < b.value && a.key < b.key
}
for (source, path) in sort {
print(" \(source) (required by \(path))")
}
}
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 {
job.alwaysGenerate || !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)
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 {
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(value: job.quality)]) 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)
}
/**
Create images of different types.
This function generates versions for the given image, including png/jpg, avif, and webp. Different pixel density versions (1x and 2x) are also generated.
- Parameter destination: The path to the destination file
*/
@discardableResult
func requireMultiVersionImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int?) -> NSSize {
// Add @1x version
_ = requireScaledMultiImage(source: source, destination: destination, requiredBy: path, width: width, desiredHeight: desiredHeight)
// Add @2x version
return requireScaledMultiImage(
source: source,
destination: destination.insert("@2x", beforeLast: "."),
requiredBy: path,
width: width * 2,
desiredHeight: desiredHeight.unwrapped { $0 * 2 })
}
@discardableResult
private func requireScaledMultiImage(source: String, destination: String, requiredBy path: String, width: Int, desiredHeight: Int?) -> NSSize {
let rawDestinationPath = destination.dropAfterLast(".")
let avifPath = rawDestinationPath + ".avif"
let webpPath = rawDestinationPath + ".webp"
let needsGeneration = !output.appendingPathComponent(avifPath).exists || !output.appendingPathComponent(webpPath).exists
let size = requireImage(at: destination, generatedFrom: source, requiredBy: path, quality: 1.0, width: width, height: desiredHeight, alwaysGenerate: needsGeneration)
multiImageJobs[destination] = path
return size
}
private func createMultiImages() {
let sort = multiImageJobs.sorted { $0.value < $1.value && $0.key < $1.key }
var count = 1
for (baseImage, path) in sort {
print(String(format: "Creating image versions: %4d / %d\r", count, sort.count), terminator: "")
fflush(stdout)
createMultiImages(from: baseImage, path: path)
count += 1
}
print(" \r", terminator: "")
}
private func createMultiImages(from source: String, path: String) {
guard generatedImages.contains(source) else {
return
}
let sourceUrl = output.appendingPathComponent(source)
let sourcePath = sourceUrl.path
guard sourceUrl.exists else {
addWarning("No image at path \(sourcePath)", destination: source, path: path)
missingImages[source] = path
return
}
let avifPath = source.dropAfterLast(".") + ".avif"
createAVIF(at: output.appendingPathComponent(avifPath).path, from: sourcePath)
generatedImages.insert(avifPath)
let webpPath = source.dropAfterLast(".") + ".webp"
createWEBP(at: output.appendingPathComponent(webpPath).path, from: sourcePath)
generatedImages.insert(webpPath)
compress(at: source)
}
private func createAVIF(at destination: String, from source: String, quality: Int = 55, effort: Int = 5) {
let folder = destination.dropAfterLast("/")
let command = "npx avif --input=\(source) --quality=\(quality) --effort=\(effort) --output=\(folder) --overwrite"
do {
_ = try FileSystem.safeShell(command)
} catch {
addWarning("Failed to create AVIF image", destination: destination, path: destination)
}
}
private func createWEBP(at destination: String, from source: String, quality: Int = 75) {
let command = "cwebp \(source) -q \(quality) -o \(destination)"
do {
_ = try FileSystem.safeShell(command)
} catch {
addWarning("Failed to create WEBP image", destination: destination, path: destination)
}
}
private func compress(at destination: String, quality: Int = 70) {
let command = "magick convert \(destination) -quality \(quality)% \(destination)"
do {
_ = try FileSystem.safeShell(command)
} catch {
addWarning("Failed to compress image", destination: destination, path: destination)
}
}
private func optimizeImages() {
let all = generatedImages
.filter { imageOptimSupportedFileExtensions.contains($0.lastComponentAfter(".")) }
.map { output.appendingPathComponent($0).path }
for i in stride(from: 0, to: all.count, by: imageOptimizationBatchSize) {
let endIndex = min(i+imageOptimizationBatchSize, all.count)
let batch = all[i..<endIndex]
print(String(format: "Optimizing images: %4d / %d\r", endIndex, all.count), terminator: "")
fflush(stdout)
if optimizeImageBatch(batch) {
optimizedImages.formUnion(batch)
}
}
print(" \r", terminator: "")
fflush(stdout)
print("\(optimizedImages.count) images optimized")
}
private func optimizeImageBatch(_ batch: ArraySlice<String>) -> Bool {
let command = "imageoptim " + batch.joined(separator: " ")
do {
_ = try FileSystem.safeShell(command)
return true
} catch {
addWarning("Failed to optimize images", destination: "", path: "")
return false
}
}
}

View File

@@ -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

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

View File

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

View File

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

View File

@@ -2,31 +2,81 @@ import Foundation
struct OverviewSectionGenerator {
private let multipleSectionsTemplate: OverviewSectionTemplate
private let singleSectionsTemplate: OverviewSectionCleanTemplate
private let factory: TemplateFactory
private let generator: ThumbnailListGenerator
init(factory: TemplateFactory) {
self.multipleSectionsTemplate = factory.overviewSection
self.singleSectionsTemplate = factory.overviewSectionClean
self.generator = ThumbnailListGenerator(factory: factory)
private let siteRoot: Element
private let results: GenerationResultsHandler
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 {
let content = sectionsContent(sections, in: parent, language: language, sectionItemCount: sectionItemCount)
if parent.showMostRecentSection {
let news = newsSectionContent(for: parent, language: language, sectionItemCount: sectionItemCount)
return news + "\n" + content
} else {
return content
}
let firstSection = firstSectionContent(for: parent, language: language, sectionItemCount: sectionItemCount)
return firstSection + content
}
private func newsSectionContent(for element: Element, language: String, sectionItemCount: Int) -> String {
let shownElements = element.mostRecentElements(sectionItemCount)
return ""
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 {
@@ -45,7 +95,7 @@ struct OverviewSectionGenerator {
style: section.thumbnailStyle)
content[.more] = metadata.moreLinkText
return multipleSectionsTemplate.generate(content)
return factory.overviewSection.generate(content)
}
.joined(separator: "\n")
}
@@ -57,6 +107,6 @@ struct OverviewSectionGenerator {
parent: section,
language: language,
style: section.thumbnailStyle)
return singleSectionsTemplate.generate(content)
return factory.overviewSectionClean.generate(content)
}
}

View File

@@ -6,64 +6,110 @@ 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
init(factory: TemplateFactory, 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 generate(page: Element, language: String, content: String) -> (content: String, includesCode: Bool) {
var hasCodeContent = false
func generatePage(from content: String) -> (content: String, headers: RequiredHeaders) {
headers = .init()
let imageModifier = Modifier(target: .images) { html, markdown in
processMarkdownImage(markdown: markdown, html: html, page: page, language: language)
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>" + swift.highlight(code) + "</pre></code>"
return "<pre><code>" + self.swift.highlight(code) + "</pre></code>"
}
hasCodeContent = true
self.headers.insert(.codeHightlighting)
return html
}
let linkModifier = Modifier(target: .links) { html, markdown in
handleLink(page: page, language: language, html: html, markdown: markdown)
self.handleLink(html: html, markdown: markdown)
}
let htmlModifier = Modifier(target: .html) { html, markdown in
handleHTML(page: page, language: language, html: html, markdown: markdown)
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])
return (parser.html(from: content), hasCodeContent)
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier, htmlModifier, headlinesModifier])
return (parser.html(from: content), headers)
}
private func handleLink(page: Element, language: String, html: String, markdown: Substring) -> String {
private func handleLink(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)
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: "]")
}
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)
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
files.expect(file: filePath, source: page.path)
results.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")
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:
@@ -73,20 +119,37 @@ struct PageContentGenerator {
return html
}
private func processMarkdownImage(markdown: Substring, html: String, page: Element, language: String) -> String {
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: ![left_title](file right_title)
// 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, ...)
// For a simple boxes: ![box](title;body)
// 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, ...)
let fileAndTitle = markdown.between("(", and: ")")
let alt = markdown.between("[", and: "]").nonEmpty
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, page: page, language: language, content: fileAndTitle)
return handleShortHandCommand(command, content: fileAndTitle)
}
let file = fileAndTitle.dropAfterFirst(" ")
@@ -94,103 +157,191 @@ struct PageContentGenerator {
let fileExtension = file.lastComponentAfter(".").lowercased()
if let _ = ImageType(fileExtension: fileExtension) {
return handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
return handleImage(file: file, rightTitle: title, leftTitle: alt, largeImageCount: &largeImageCount)
}
if let _ = VideoType(rawValue: fileExtension) {
return handleVideo(page: page, file: file, optionString: alt)
return handleVideo(file: file, optionString: alt)
}
if fileExtension == "svg" {
return handleSvg(page: page, file: file, area: 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)
}
return handleFile(page: page, file: file, fileExtension: fileExtension)
}
private func handleShortHandCommand(_ command: ShorthandMarkdownKey, page: Element, language: String, content: String) -> String {
private func handleShortHandCommand(_ command: ShorthandMarkdownKey, content: String) -> String {
switch command {
case .downloadButtons:
return handleDownloadButtons(page: page, content: content)
return handleDownloadButtons(content: content)
case .externalLink:
return handleExternalButtons(page: page, content: content)
return handleExternalButtons(content: content)
case .includedHtml:
return handleExternalHTML(page: page, file: content)
return handleExternalHTML(file: content)
case .box:
return handleSimpleBox(page: page, content: content)
return handleSimpleBox(content: content)
case .pageLink:
return handlePageLink(page: page, language: language, pageId: content)
return handlePageLink(pageId: content)
case .model3d:
return handle3dModel(content: content)
}
}
private func handleImage(page: Element, file: String, rightTitle: String?, leftTitle: String?) -> String {
private func handleImage(file: String, rightTitle: String?, leftTitle: String?, largeImageCount: inout Int) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
let size = files.requireFullSizeMultiVersionImage(
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 content: [PageImageTemplate.Key : String] = [
.image: file.dropAfterLast("."),
.imageExtension: file.lastComponentAfter("."),
.width: "\(Int(size.width))",
.height: "\(Int(size.height))",
.leftText: leftTitle ?? "",
.rightText: rightTitle ?? ""]
return factory.image.generate(content)
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(page: Element, file: String, optionString: String?) -> String {
private func handleVideo(file: String, optionString: String?) -> String {
let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in
string.components(separatedBy: " ").compactMap { optionText 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 {
log.add(warning: "Unknown video option \(optionText)", source: page.path)
results.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)
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 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 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 handleFile(page: Element, file: String, fileExtension: String) -> String {
log.add(warning: "Unhandled file \(file) with extension \(fileExtension)", source: page.path)
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(page: Element, content: String) -> String {
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 {
log.add(warning: "Invalid button definition", source: page.path)
results.warning("Invalid download definition with \(parts)", source: page.path)
return nil
}
let file = parts[0].trimmed
@@ -199,23 +350,26 @@ struct PageContentGenerator {
// Ensure that file is available
let filePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: filePath)
results.require(file: filePath, source: page.path)
return (file, title, downloadName)
}
return factory.html.downloadButtons(buttons)
}
private func handleExternalButtons(page: Element, content: String) -> String {
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 {
log.add(warning: "Invalid external link definition", source: page.path)
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 url = parts[0].trimmed
let title = parts[1].trimmed
return (url, title)
@@ -223,24 +377,15 @@ struct PageContentGenerator {
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 handleExternalHTML(file: String) -> String {
let path = page.pathRelativeToRootForContainedInputFile(file)
return results.getContentOfRequiredFile(at: path, source: page.path) ?? ""
}
private func handleSimpleBox(page: Element, content: String) -> String {
private func handleSimpleBox(content: String) -> String {
let parts = content.components(separatedBy: ";")
guard parts.count > 1 else {
log.add(error: "Invalid box specification", source: page.path)
results.warning("Invalid box specification", source: page.path)
return ""
}
let title = parts[0]
@@ -248,17 +393,24 @@ struct PageContentGenerator {
return factory.makePlaceholder(title: title, text: text)
}
private func handlePageLink(page: Element, language: String, pageId: String) -> String {
private func handlePageLink(pageId: String) -> String {
guard let linkedPage = siteRoot.find(pageId) else {
log.add(warning: "Page id '\(pageId)' not found", source: page.path)
// 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)
@@ -286,4 +438,29 @@ struct PageContentGenerator {
// 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

@@ -7,24 +7,29 @@ struct PageGenerator {
private let contentGenerator: PageContentGenerator
init(factory: LocalizedSiteTemplate, siteRoot: Element) {
private let results: GenerationResultsHandler
init(factory: LocalizedSiteTemplate, siteRoot: Element, results: GenerationResultsHandler) {
self.factory = factory
self.contentGenerator = PageContentGenerator(factory: factory.factory, siteRoot: siteRoot)
self.results = results
self.contentGenerator = PageContentGenerator(factory: factory.factory, siteRoot: siteRoot, results: results)
}
func generate(page: Element, language: String, previousPage: Element?, nextPage: Element?) {
guard !page.isExternalPage else {
results.didCompletePage()
return
}
let path = page.fullPageUrl(for: language)
let inputContentPath = page.path + "/\(language).md"
let metadata = page.localized(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)
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)
content[.topBar] = factory.topBar.generate(sectionUrl: sectionUrl, languageButton: nextLanguage, page: page)
content[.contentClass] = "content"
@@ -35,23 +40,17 @@ struct PageGenerator {
content[.previousPageUrl] = navLink(from: page, to: previousPage, language: language)
content[.nextPageLinkText] = nextText(for: 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()
content[.footer] = (content[.footer].unwrapped { $0 + "\n" } ?? "") + highlightCode
}
let url = files.urlInOutputFolder(path)
if page.state == .draft {
files.isDraft(path: page.path)
} else if pageIsEmpty, page.state != .hidden {
files.isEmpty(page: path)
results.markPageAsDraft(page: page.path)
}
guard factory.page.generate(content, to: url) else {
return
}
files.generated(page: path)
factory.page.generate(content, to: path, source: page.path)
}
private func navLink(from element: Element, to destination: Element?, language: String) -> String? {
@@ -75,14 +74,14 @@ struct PageGenerator {
return factory.factory.html.makeNextText(text)
}
private func makeContent(page: Element, metadata: Element.LocalizedMetadata, language: String, path: String) -> (content: String, includesCode: Bool, isEmpty: Bool) {
if let raw = files.contentOfMdFile(atPath: path, source: page.path)?.trimmed.nonEmpty {
private func makeContent(page: Element, metadata: Element.LocalizedMetadata, language: String, path: String) -> (content: String, headers: RequiredHeaders) {
if let raw = results.getContentOfMdFile(at: path, source: page.path)?.trimmed.nonEmpty {
let (content, includesCode) = contentGenerator.generate(page: page, language: language, content: raw)
return (content, includesCode, false)
return (content, includesCode)
} else {
let (content, includesCode) = contentGenerator.generate(page: page, language: language, content: metadata.placeholderText)
let placeholder = factory.factory.makePlaceholder(title: metadata.placeholderTitle, text: content)
return (placeholder, includesCode, true)
return (placeholder, includesCode)
}
}

View File

@@ -2,15 +2,19 @@ import Foundation
struct PageHeadGenerator {
// TODO: Add to configuration
static let linkPreviewDesiredImageWidth = 1600
let factory: TemplateFactory
init(factory: TemplateFactory) {
private let results: GenerationResultsHandler
init(factory: TemplateFactory, results: GenerationResultsHandler) {
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)
var content = [PageHeadTemplate.Key : String]()
@@ -24,25 +28,24 @@ struct PageHeadGenerator {
let linkPreviewImageName = "thumbnail-link.\(image.lastComponentAfter("."))"
let sourceImagePath = page.pathRelativeToRootForContainedInputFile(image)
let destinationImagePath = page.pathRelativeToRootForContainedInputFile(linkPreviewImageName)
files.requireSingleImage(
results.requireSingleImage(
source: sourceImagePath,
destination: destinationImagePath,
requiredBy: page.path,
width: PageHeadGenerator.linkPreviewDesiredImageWidth)
content[.image] = factory.html.linkPreviewImage(file: linkPreviewImageName)
}
content[.customPageContent] = page.customHeadContent()
if includesCode {
let scriptPath = "assets/js/highlight.js"
let relative = page.relativePathToOtherSiteElement(file: scriptPath)
let includeText = factory.html.scriptInclude(path: relative)
if let head = content[.customPageContent] {
content[.customPageContent] = head + "\n" + includeText
} else {
content[.customPageContent] = includeText
}
}
content[.customPageContent] = results.getContentOfOptionalFile(at: page.additionalHeadContentPath, source: page.path)
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)
return factory.html.scriptInclude(path: relative, asModule: option.asModule)
}
content[.customPageContent] = head.joined(separator: "\n")
return factory.pageHead.generate(content)
}
}

View File

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

View File

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

View File

@@ -4,8 +4,11 @@ struct ThumbnailListGenerator {
private let factory: TemplateFactory
init(factory: TemplateFactory) {
private let results: GenerationResultsHandler
init(factory: TemplateFactory, results: GenerationResultsHandler) {
self.factory = factory
self.results = results
}
func generateContent(items: [Element], parent: Element, language: String, style: ThumbnailStyle) -> String {
@@ -27,6 +30,7 @@ struct ThumbnailListGenerator {
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 {
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
@@ -37,7 +41,7 @@ struct ThumbnailListGenerator {
factory.largeThumbnail.makeCorner(text: $0)
}
files.requireMultiVersionImage(
results.requireMultiVersionImage(
source: thumbnailSourcePath,
destination: thumbnailDestNoExtension + ".jpg",
requiredBy: item.path,

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

@@ -124,10 +124,10 @@ final class MetadataInfoLogger {
// MARK: Printing
private func printMetadataScanUpdate() {
print(String(format: "Scanning source files: %4d pages found \r", numberOfMetadataFiles), terminator: "")
print(" Pages found: \(numberOfMetadataFiles) \r", terminator: "")
}
func printMetadataScanOverview() {
func printMetadataScanOverview(languages: Int) {
var notes = [String]()
func addIfNotZero<S>(_ sequence: Array<S>, _ name: String) {
guard sequence.count > 0 else {
@@ -139,40 +139,50 @@ final class MetadataInfoLogger {
addIfNotZero(errors, "errors")
addIfNotZero(unreadableMetadata, "unreadable files")
addIfNotZero(unusedProperties, "unused properties")
addIfNotZero(invalidProperties, "invalidProperties")
addIfNotZero(unknownProperties, "unknownProperties")
addIfNotZero(missingProperties, "missingProperties")
addIfNotZero(invalidProperties, "invalid properties")
addIfNotZero(unknownProperties, "unknown properties")
addIfNotZero(missingProperties, "missing properties")
print(" Number of pages: \(numberOfMetadataFiles)")
print(" Notes: " + notes.joined(separator: ", "))
print(" Pages found: \(numberOfMetadataFiles) ")
print(" Languages: \(languages)")
if !notes.isEmpty {
print(" Notes: " + notes.joined(separator: ", "))
}
}
func writeResultsToFile(in folder: URL) throws {
let url = folder.appendingPathComponent("Metadata issues.txt")
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] = []
if !errors.isEmpty {
lines += ["Errors:"] + errors.map { "\($0.source): \($0.message)" }
}
if !warnings.isEmpty {
lines += ["Warnings:"] + warnings.map { "\($0.source): \($0.message)" }
}
if !unreadableMetadata.isEmpty {
lines += ["Unreadable files:"] + unreadableMetadata.map { "\($0.source): \($0.error)" }
}
if !unusedProperties.isEmpty {
lines += ["Unused properties:"] + unusedProperties.map { "\($0.source): \($0.name)" }
}
if !invalidProperties.isEmpty {
lines += ["Invalid properties:"] + invalidProperties.map { "\($0.source): \($0.name) (\($0.reason))" }
}
if !unknownProperties.isEmpty {
lines += ["Unknown properties:"] + unknownProperties.map { "\($0.source): \($0.name)" }
}
if !missingProperties.isEmpty {
lines += ["Missing properties:"] + missingProperties.map { "\($0.source): \($0.name)" }
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)
try data?.createFolderAndWrite(to: url)
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"
let raw: String
let results: GenerationResultsHandler
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,36 @@
import Foundation
enum PageImageTemplateKey: String, CaseIterable {
case altText = "ALT_TEXT"
case image = "IMAGE"
case imageExtension = "IMAGE_EXT"
case width = "WIDTH"
case height = "HEIGHT"
case leftText = "LEFT_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 {
enum Key: String, CaseIterable {
case image = "IMAGE"
case imageExtension = "IMAGE_EXT"
case width = "WIDTH"
case height = "HEIGHT"
case leftText = "LEFT_TEXT"
case rightText = "RIGHT_TEXT"
}
typealias Key = PageImageTemplateKey
static let templateName = "image.html"
let raw: String
let results: GenerationResultsHandler
}

View File

@@ -3,6 +3,7 @@ import Foundation
struct PageLinkTemplate: Template {
enum Key: String, CaseIterable {
case altText = "ALT_TEXT"
case url = "URL"
case image = "IMAGE"
case title = "TITLE"
@@ -15,6 +16,8 @@ struct PageLinkTemplate: Template {
let raw: String
let results: GenerationResultsHandler
func makePath(components: [String]) -> String {
components.joined(separator: " » ") // &ensp;&raquo;&ensp;")
}

View File

@@ -23,6 +23,8 @@ struct PageVideoTemplate: Template {
let raw: String
let results: GenerationResultsHandler
func generate<T>(sources: [VideoSource], options: T) -> String where T: Sequence, T.Element == VideoOption {
let sourcesCode = sources.map(makeSource).joined(separator: "\n")
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,6 +6,7 @@ protocol ThumbnailTemplate {
}
enum ThumbnailKey: String, CaseIterable {
case altText = "ALT_TEXT"
case url = "URL"
case image = "IMAGE"
case title = "TITLE"
@@ -20,6 +21,8 @@ struct LargeThumbnailTemplate: Template, ThumbnailTemplate {
let raw: String
let results: GenerationResultsHandler
func makeCorner(text: String) -> String {
"<span class=\"corner\"><span>\(text)</span></span>"
}
@@ -36,6 +39,8 @@ struct SquareThumbnailTemplate: Template, ThumbnailTemplate {
static let templateName = "thumbnail-square.html"
let raw: String
let results: GenerationResultsHandler
}
struct SmallThumbnailTemplate: Template, ThumbnailTemplate {
@@ -45,5 +50,7 @@ struct SmallThumbnailTemplate: Template, ThumbnailTemplate {
static let templateName = "thumbnail-small.html"
let raw: String
let results: GenerationResultsHandler
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,6 @@
import Foundation
import ArgumentParser
let log = ValidationLog()
var files: FileSystem!
@main
struct CHGenerator: ParsableCommand {
@@ -15,28 +13,22 @@ struct CHGenerator: ParsableCommand {
}
}
private func loadSiteData(in folder: URL) throws -> Element? {
let log = MetadataInfoLogger(input: folder)
print("--- SOURCE FILES -----------------------------------")
let root = Element(atRoot: folder, log: log)
print(" ")
log.printMetadataScanOverview()
print(" ")
try log.writeResultsToFile(in: files.generatorInfoFolder)
return root
}
private func loadConfiguration(at configPath: String) -> Configuration? {
print("--- CONFIGURATION ----------------------------------")
print("")
print(" ")
print(" Configuration file: \(configPath)")
let configUrl = URL(fileURLWithPath: configPath)
let config: Configuration
guard configUrl.exists else {
print(" Error: Configuration file not found")
return nil
}
var config: Configuration
do {
let data = try Data(contentsOf: configUrl)
config = try JSONDecoder().decode(from: data)
config.adjustPathsRelative(to: configUrl.deletingLastPathComponent())
} catch {
print(" Configuration error: \(error)")
print(" Configuration error: \(error)")
return nil
}
config.printOverview()
@@ -44,30 +36,152 @@ private func loadConfiguration(at configPath: String) -> Configuration? {
return config
}
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,
to: configuration.outputDirectory,
configuration: configuration,
fileUpdates: fileUpdates,
pageMap: pageMap,
pageCount: pageCount)
defer { results.printOverview() }
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
}
// 1. Load configuration
guard let configuration = loadConfiguration(at: configPath) else {
return
}
files = .init(
in: configuration.contentDirectory,
to: configuration.outputDirectory,
configuration: configuration)
let runFolder = configuration.contentDirectory.appendingPathComponent("run")
// 2. Scan site elements
guard let siteRoot = try loadSiteData(in: configuration.contentDirectory) else {
guard let (siteRoot, pageMap) = try loadSiteData(in: configuration.contentDirectory, runFolder: runFolder) else {
return
}
let siteGenerator = try SiteGenerator()
siteGenerator.generate(site: siteRoot)
files.printGeneratedPages()
files.printEmptyPages()
files.printDraftPages()
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))")
}
files.createImages()
files.copyRequiredFiles()
files.printExternalFiles()
files.writeDetectedFileChangesToDisk()
// 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,
"minifyCSSandJS" : true,
"fullScreenImageWidth" : 4000,
"minifyJavaScript" : false,
"minifyCSS" : false,
"createMdFilesIfMissing" : false,
"contentPath" : "/path/to/content/folder",
"outputPath" : "/path/to/output/folder")

View File

@@ -1,14 +1,17 @@
#!/bin/bash
# Install the required dependencies for CHGenerator
# Note: The following is only required if `minifyCSSandJS=True`
# is set in the generator configuration
# Install magick to resize images
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
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
npm install clean-css-cli -g