Compare commits

..

101 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
Christoph Hagen
6e24c27fdc Determine elements for news section 2022-12-01 15:39:39 +01:00
Christoph Hagen
92d832dc44 Remove global site root 2022-12-01 15:25:55 +01:00
Christoph Hagen
90d2573d0c Add imageOptim to install script 2022-12-01 15:19:41 +01:00
Christoph Hagen
94375f3a81 Remove global configuration and improve printing 2022-12-01 15:19:17 +01:00
Christoph Hagen
58eae51d40 Shorten metadata logging 2022-12-01 15:03:29 +01:00
Christoph Hagen
27b8d5b3ee Add warnings and errors to output 2022-12-01 14:52:36 +01:00
Christoph Hagen
1ceba25d4f Improve logging during element scanning 2022-12-01 14:50:26 +01:00
Christoph Hagen
4c2c4b7dd3 Optimize images with ImageOptim 2022-11-30 15:29:51 +01:00
Christoph Hagen
58f7642ca5 Fix links and images in related content 2022-11-27 22:06:18 +01:00
Christoph Hagen
112bbe252c Generate avif and webp image versions 2022-11-27 20:31:56 +01:00
Christoph Hagen
c82080db82 Allow custom thumbnail paths in metadata 2022-09-29 21:23:41 +02:00
Christoph Hagen
9d2f1e4c90 Remove debug output 2022-09-29 16:24:04 +02:00
Christoph Hagen
39a53cdb1d Generate all pages again 2022-09-29 16:23:58 +02:00
Christoph Hagen
31edd35463 Print image overviews 2022-09-26 17:00:39 +02:00
Christoph Hagen
b39066f47f Fix memory leaks, sizes for image generation 2022-09-26 17:00:25 +02:00
Christoph Hagen
152a76935b Generate navigation links 2022-09-25 22:07:34 +02:00
Christoph Hagen
756629d2dc Remove unused variable 2022-09-25 17:24:03 +02:00
Christoph Hagen
f2ee06b1d7 Add command for pretty page links 2022-09-25 17:19:07 +02:00
Christoph Hagen
66dcd43082 Access site root globally 2022-09-23 09:22:38 +02:00
Christoph Hagen
fdd4c0e4d9 Find element by id 2022-09-23 09:22:00 +02:00
Christoph Hagen
6c21d8c857 Add typed shorthand markdown commands 2022-09-18 17:49:50 +02:00
Christoph Hagen
53500c31f6 Add convenience box command 2022-09-18 17:21:57 +02:00
Christoph Hagen
396e03279f Improve image generation prints 2022-09-18 16:48:15 +02:00
Christoph Hagen
3872a3e419 Add path parameter to image generation 2022-09-18 16:47:13 +02:00
Christoph Hagen
763b90f689 Extract Digest extension to separate file 2022-09-18 16:45:34 +02:00
Christoph Hagen
b47c551160 Remove unnecessary aliases 2022-09-16 15:33:14 +02:00
Christoph Hagen
c727bdf91e Scale images to integer heights 2022-09-16 15:32:55 +02:00
Christoph Hagen
9e6ee2c499 Use argument parser 2022-09-09 13:29:31 +02:00
Christoph Hagen
2a9061c1d6 Convert Xcode project to swift package 2022-09-09 11:18:32 +02:00
Christoph Hagen
64db75fb44 Read config path from command line 2022-09-09 11:00:12 +02:00
Christoph Hagen
9965e1a3ff Remove unneeded warning 2022-09-09 10:59:43 +02:00
Christoph Hagen
e8513a896b Add option to generate empty md files 2022-09-09 10:59:26 +02:00
Christoph Hagen
7354d6b58e Allow inclusion of html content 2022-09-08 13:12:55 +02:00
Christoph Hagen
3d9551117d Ignore config file 2022-09-08 13:01:55 +02:00
Christoph Hagen
1405fa5ee7 Add installation script for minifiers 2022-09-08 13:01:45 +02:00
Christoph Hagen
abd42e4909 Specify required images in metadata 2022-09-08 13:01:32 +02:00
Christoph Hagen
570cebb5d0 Read generator configuration from file 2022-09-08 09:38:35 +02:00
Christoph Hagen
81b373fb5a Correctly label non-throwing functions
Remove throws from Element constructor
2022-09-08 09:38:35 +02:00
Christoph Hagen
28623d1209 Add paths to configuration 2022-09-05 16:08:06 +02:00
Christoph Hagen
cfb68f5237 Minify JS and CSS files 2022-09-05 15:56:05 +02:00
Christoph Hagen
a69da0fa35 Allow directories for external files 2022-09-05 12:59:32 +02:00
Christoph Hagen
1c13f4fc60 Allow header selection for pages 2022-09-04 20:36:43 +02:00
Christoph Hagen
a7e7bc21fc Allow whitespaces in svg size element 2022-09-04 17:48:13 +02:00
Christoph Hagen
aa701d9793 Make highlight script path relative 2022-09-04 17:47:35 +02:00
Christoph Hagen
cec60e9ff2 Make top bar link relative 2022-09-04 17:47:13 +02:00
Christoph Hagen
9a40da63d3 Allow absolute urls for link preview thumbnails 2022-09-04 17:45:44 +02:00
Christoph Hagen
9408b91741 Use overview generator for start page 2022-09-04 17:45:29 +02:00
Christoph Hagen
7f65065f72 Treat placeholder text as markdown 2022-09-02 23:19:30 +02:00
Christoph Hagen
d1c418af3e Improve overview of modified pages 2022-09-02 23:19:13 +02:00
Christoph Hagen
6a2d63462e Improve display of required files 2022-09-01 10:55:42 +02:00
Christoph Hagen
4dc56e5dfe Print draft pages 2022-08-31 09:02:40 +02:00
Christoph Hagen
1537aaab01 Hide language buttons again if page is empty 2022-08-31 08:46:23 +02:00
92 changed files with 4409 additions and 2640 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
config.json
.build
.swiftpm
Package.resolved
Issues.md

26
Package.swift Normal file
View File

@ -0,0 +1,26 @@
// swift-tools-version: 5.6
import PackageDescription
let package = Package(
name: "CHGenerator",
platforms: [.macOS(.v10_15)],
products: [
.executable(
name: "CHGenerator",
targets: ["Generator"]),
],
dependencies: [
.package(url: "https://github.com/johnsundell/ink.git", from: "0.5.0"),
.package(url: "https://github.com/JohnSundell/Splash", from: "0.16.0"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
],
targets: [
.executableTarget(
name: "Generator",
dependencies: [
.product(name: "Ink", package: "ink"),
.product(name: "Splash", package: "Splash"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]),
]
)

View File

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

View File

@ -57,7 +57,7 @@ extension Element {
- Note: If this value is inherited from the parent, if it is not defined. There must be at least one
element in the path that defines this property.
*/
let moreLinkText: String
let moreLinkText: String?
/**
The text on the back navigation link of **contained** elements.
@ -119,76 +119,92 @@ extension Element {
It can also be set to manually write a page.
*/
let externalUrl: String?
/**
The text to display for content related to the current page.
This property is mandatory at root level, and is propagated to child elements.
*/
let relatedContentText: String
/**
The text to display on the navigation element pointing to this element as the next page.
This property is mandatory at root level, and is propagated to child elements.
*/
let navigationTextAsNextPage: String
/**
The text to display on a navigation element pointing to this element as the previous page.
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?
}
}
extension Element.LocalizedMetadata {
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata) {
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata, log: MetadataInfoLogger) {
// Go through all elements and check them for completeness
// In the end, check that all required elements are present
var isComplete = true
func markAsIncomplete() {
isComplete = false
}
var isValid = true
let source = "root"
self.language = log
.required(data.language, name: "language", source: source)
.ifNil(markAsIncomplete) ?? ""
self.title = log
.required(data.title, name: "title", source: source)
.ifNil(markAsIncomplete) ?? ""
self.language = log.required(data.language, name: "language", source: source, &isValid)
self.title = log.required(data.title, name: "title", source: source, &isValid)
self.subtitle = data.subtitle
self.description = data.description
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
self.linkPreviewImage = log
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
self.linkPreviewImage = data.linkPreviewImage
let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle
self.linkPreviewDescription = log
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
self.backLinkText = log
.required(data.backLinkText, name: "backLinkText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.linkPreviewDescription = log.required(linkPreviewDescription, name: "linkPreviewDescription", source: source, &isValid)
self.moreLinkText = data.moreLinkText
self.backLinkText = log.required(data.backLinkText, name: "backLinkText", source: source, &isValid)
self.parentBackLinkText = "" // Root has no parent
self.placeholderTitle = log
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
.ifNil(markAsIncomplete) ?? ""
self.placeholderText = log
.required(data.placeholderText, name: "placeholderText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.placeholderTitle = log.required(data.placeholderTitle, name: "placeholderTitle", source: source, &isValid)
self.placeholderText = log.required(data.placeholderText, name: "placeholderText", source: source, &isValid)
self.titleSuffix = data.titleSuffix
self.thumbnailSuffix = log.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
self.cornerText = log.unused(data.cornerText, "cornerText", source: source)
self.externalUrl = log.unexpected(data.externalUrl, name: "externalUrl", source: source)
self.externalUrl = log.unused(data.externalUrl, "externalUrl", source: source)
self.relatedContentText = log.required(data.relatedContentText, name: "relatedContentText", source: source, &isValid)
self.navigationTextAsNextPage = log.required(data.navigationTextAsNextPage, name: "navigationTextAsNextPage", source: source, &isValid)
self.navigationTextAsPreviousPage = log.required(data.navigationTextAsPreviousPage, name: "navigationTextAsPreviousPage", source: source, &isValid)
self.mostRecentTitle = data.mostRecentTitle
self.featuredTitle = data.featuredTitle
guard isComplete else {
guard isValid else {
return nil
}
}
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata) {
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata, log: MetadataInfoLogger) {
// Go through all elements and check them for completeness
// In the end, check that all required elements are present
var isComplete = true
func markAsIncomplete() {
isComplete = false
}
var isValid = true
self.language = parent.language
self.title = log
.required(data.title, name: "title", source: source)
.ifNil(markAsIncomplete) ?? ""
self.title = log.required(data.title, name: "title", source: source, &isValid)
self.subtitle = data.subtitle
self.description = data.description
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
self.linkPreviewImage = log
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
self.linkPreviewImage = data.linkPreviewImage
let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle
self.linkPreviewDescription = log
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = log.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
self.linkPreviewDescription = log.required(linkPreviewDescription, name: "linkPreviewDescription", source: source, &isValid)
self.moreLinkText = log.required(data.moreLinkText ?? parent.moreLinkText, name: "moreLinkText", source: source, &isValid)
self.backLinkText = data.backLinkText ?? data.title ?? ""
self.parentBackLinkText = parent.backLinkText
self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle
@ -197,33 +213,14 @@ extension Element.LocalizedMetadata {
self.thumbnailSuffix = data.thumbnailSuffix
self.cornerText = data.cornerText
self.externalUrl = data.externalUrl
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 isComplete else {
guard isValid else {
return nil
}
}
}
// MARK: Thumbnails
extension Element {
static let defaultThumbnailName = "thumbnail.jpg"
static func localizedThumbnailName(for language: String) -> String {
"thumbnail-\(language).jpg"
}
static func findThumbnail(for language: String, in folder: URL) -> String? {
let localizedThumbnail = localizedThumbnailName(for: language)
let localizedThumbnailUrl = folder.appendingPathComponent(localizedThumbnail)
if localizedThumbnailUrl.exists {
return localizedThumbnail
}
let defaultThumbnailUrl = folder.appendingPathComponent(defaultThumbnailName)
if defaultThumbnailUrl.exists {
return defaultThumbnailName
}
return nil
}
}

View File

@ -0,0 +1,799 @@
import Foundation
struct Element {
static let overviewItemCountDefault = 6
/**
The default unique id for the root element
*/
static let defaultRootId = "root"
/**
The unique id of the element.
The id is used for short-hand links to pages, in the form of `![page](page_id)`
for thumbnail previews or `[text](page:page_id)` for simple links.
- Note: The default id for the root element is specified by ``defaultRootId``
The id can be manually specified using ``GenericMetadata.id``,
otherwise it is set to the name of the element folder.
*/
let id: String
/**
The author of the content.
If no author is set, then the author from the parent element is used.
*/
let author: String
/**
The title used in the top bar of the website, next to the logo.
This title can be HTML content, and only the root level value is used.
*/
let topBarTitle: String
/**
The (start) date of the element.
The date is printed on content pages and may also used for sorting elements,
depending on the `useManualSorting` property of the parent.
*/
let date: Date?
/**
The end date of the element.
This property can be used to specify a date range for a content page.
*/
let endDate: Date?
/**
The deployment state of the page.
- Note: This property defaults to ``PageState.standard`
*/
let state: PageState
/**
The sort index of the page for manual sorting.
- Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set.
*/
let sortIndex: Int?
/**
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.
*/
let externalFiles: Set<String>
/**
Specifies additional files which should be copied to the destination when generating the content.
- Note: This property defaults to an empty set.
*/
let requiredFiles: Set<String>
/**
Additional images required by the element.
These images are specified as: `source_name destination_name width (height)`.
*/
let images: [ManualImage]
/**
The path to the thumbnail file.
This property is optional, and defaults to ``GenericMetadata.defaultThumbnailName``.
Note: The generator first looks for localized versions of the thumbnail by appending `-[lang]` to the file name,
e.g. `customThumb-en.jpg`. If no file is found, then the specified file is tried.
*/
let thumbnailPath: String
/**
The style of thumbnail to use when generating overviews.
- Note: This property is only relevant for sections.
- Note: This property is inherited from the parent if not specified.
*/
let thumbnailStyle: ThumbnailStyle
/**
Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`.
- Note: This property is only relevant for sections.
- Note: This property defaults to `false`
*/
let useManualSorting: Bool
/**
The number of items to show when generating overviews of this element.
- Note: This property is only relevant for sections.
- Note: This property is inherited from the parent if not specified.
*/
let overviewItemCount: Int
/**
Indicate the header type to be generated automatically.
If this option is set to `none`, then custom header code should be present in the page source files
- Note: If not specified, this property defaults to `left`.
- Note: Overview pages are always using `center`.
*/
let headerType: HeaderType
/**
Indicate that the overview section should contain a `Newest Content` section before the other sections.
- Note: If not specified, this property defaults to `false`
*/
let showMostRecentSection: Bool
/**
Indicate that the overview section should contain a `Featured Content` section before the other sections.
The elements are the page ids of the elements contained in the feature.
- Note: If not specified, this property defaults to `false`
*/
let featuredPages: [String]
/**
The localized metadata for each language.
*/
let languages: [LocalizedMetadata]
/**
All elements contained within the element.
If the element is a section, then this property contains the pages or subsections within.
*/
var elements: [Element] = []
/**
The url of the element's folder in the source hierarchy.
- Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property.
*/
let inputFolder: URL
/**
The path to the element's folder in the source hierarchy (without a leading slash).
*/
let path: String
/**
Create the root element of a site.
The root element will recursively move into subfolders and build the site content
by looking for metadata files in each subfolder.
- Parameter folder: The root folder of the site content.
- Note: Uses global objects.
*/
init?(atRoot folder: URL, log: MetadataInfoLogger) {
self.inputFolder = folder
self.path = ""
let source = GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source, log: log) else {
return nil
}
var isValid = true
self.id = metadata.customId ?? Element.defaultRootId
self.author = log.required(metadata.author, name: "author", source: source, &isValid)
self.topBarTitle = log.required(metadata.topBarTitle, name: "topBarTitle", source: source, &isValid)
self.date = log.castUnused(metadata.date, "date", source: source)
self.endDate = log.castUnused(metadata.endDate, "endDate", source: source)
self.state = log.cast(metadata.state, "state", source: source)
self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source)
self.externalFiles = metadata.externalFiles ?? []
self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: "", 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)
}
// All properties initialized
guard !languages.isEmpty else {
log.error("No languages found", source: source)
return nil
}
guard isValid else {
return nil
}
//files.add(page: path, id: id)
self.readElements(in: folder, source: nil, log: log)
}
mutating func readElements(in folder: URL, source: String?, log: MetadataInfoLogger) {
let subFolders: [URL]
do {
subFolders = try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.isDirectory }
} catch {
log.error("Failed to read subfolders: \(error)", source: source ?? "root")
return
}
self.elements = subFolders.compactMap { subFolder in
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent
return Element(parent: self, folder: subFolder, path: s, log: log)
}
}
init?(parent: Element, folder: URL, path: String, log: MetadataInfoLogger) {
self.inputFolder = folder
self.path = path
let source = path + "/" + GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source, log: log) else {
return nil
}
let state: PageState = log.cast(metadata.state, "state", source: source)
guard state != .ignored else {
return nil
}
var isValid = true
self.id = metadata.customId ?? folder.lastPathComponent
self.author = metadata.author ?? parent.author
self.topBarTitle = log.unused(metadata.topBarTitle, "topBarTitle", source: source)
self.date = metadata.date.unwrapped { log.cast($0, "date", 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, 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)
return nil
}
return .init(folder: folder, data: data, source: source, parent: parentData, log: log)
}
// Check that each 'language' tag is present, and that all languages appear in the parent
log.required(metadata.languages, name: "languages", source: source, &isValid)
.compactMap { log.required($0.language, name: "language", source: source, &isValid) }
.filter { language in
!parent.languages.contains { $0.language == language }
}
.forEach {
log.warning("Language '\($0)' not found in parent, so not generated", source: source)
}
// All properties initialized
if self.date == nil, !parent.useManualSorting {
log.error("No 'date', but parent defines 'useManualSorting' = false", source: source)
}
if date == nil {
log.unused(self.endDate, "endDate", source: source)
}
if self.sortIndex == nil, state != .hidden, parent.useManualSorting {
log.error("No 'sortIndex', but parent defines 'useManualSorting' = true", source: source)
}
guard isValid else {
return nil
}
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
}
}
// MARK: Paths
extension Element {
/**
The localized html file name for a language, including a leading slash.
*/
static func htmlPagePathAddition(for language: String) -> String {
"/" + htmlPageName(for: language)
}
/**
The localized html file name for a language, without the leading slash.
*/
static func htmlPageName(for language: String) -> String {
"\(language).html"
}
var containsElements: Bool {
!elements.isEmpty
}
var hasNestingElements: Bool {
elements.contains { $0.hasVisibleChildren }
}
func itemsForOverview(_ count: Int? = nil) -> [Element] {
if let shownItemCount = count {
return Array(sortedItems.prefix(shownItemCount))
} else {
return sortedItems
}
}
func mostRecentElements(_ count: Int) -> [Element] {
guard self.thumbnailStyle == .large else {
return []
}
guard self.containsElements else {
return [self]
}
let all = shownItems
.reduce(into: [Element]()) { $0 += $1.mostRecentElements(count) }
.filter { $0.thumbnailStyle == .large && $0.state == .standard && $0.date != nil }
.sorted { $0.date! > $1.date! }
return Array(all.prefix(count))
}
var sortedItems: [Element] {
if useManualSorting {
return shownItems.sorted { $0.sortIndex! < $1.sortIndex! }
}
return shownItems.sorted { $0.date! > $1.date! }
}
private var shownItems: [Element] {
elements.filter { $0.state.isShownInOverview }
}
var linkedElements: [LinkedElement] {
let items = sortedItems.filter { $0.state == .standard }
let connected = items.enumerated().map { i, element in
let previous = i+1 < items.count ? items[i+1] : nil
let next = i > 0 ? items[i-1] : nil
return (previous, element, next)
}
return connected + elements.filter { $0.state != .standard }.map { (nil, $0, nil )}
}
/**
The url of the top-level section of the element.
*/
func sectionUrl(for language: String) -> String {
path.components(separatedBy: "/").first! + Element.htmlPagePathAddition(for: language)
}
/**
Create a relative link to another file in the tree.
- Parameter file: The full path of the target file, including localization
- 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: "/")
let pageParts = file.components(separatedBy: "/")
// Find the common elements of the path, which can be discarded
var index = 0
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,
// before going up to the target page
let allParts = [String](repeating: "..", count: ownParts.count-index)
+ pageParts.dropFirst(index)
return allParts.joined(separator: "/")
}
/**
The relative path to the site root.
*/
var pathToRoot: String? {
guard path != "" else {
return nil
}
let downPathCount = path.components(separatedBy: "/").count
return [String](repeating: "..", count: downPathCount).joined(separator: "/")
}
/**
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
This function is used to copy required input files and to generate images
*/
func pathRelativeToRootForContainedInputFile(_ filePath: String) -> String {
Element.relativeToRoot(filePath: filePath, folder: path)
}
/**
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
This function is used to copy required input files and to generate images
*/
func nonAbsolutePathRelativeToRootForContainedInputFile(_ filePath: String) -> String? {
Element.containedFileRelativeToRoot(filePath: filePath, folder: path)
}
static func relativeToRoot(filePath: String, folder path: String) -> String {
containedFileRelativeToRoot(filePath: filePath, folder: path) ?? filePath
}
static func containedFileRelativeToRoot(filePath: String, folder path: String) -> String? {
if path == "" {
return filePath
}
if filePath.hasPrefix("/") || filePath.hasPrefix("http") || filePath.hasPrefix("mailto:") {
return nil
}
return "\(path)/\(filePath)"
}
/**
Convert a set of relative paths to paths that are relative to the root element.
- Parameter input: The set of paths to convert.
- Parameter path: The path to the folder where the paths are currently relative to.
*/
static func rootPaths(for input: Set<String>?, path: String) -> Set<String> {
guard let input = input else {
return []
}
return Set(input.map { relativeToRoot(filePath: $0, folder: path) })
}
func relativePathToFileWithPath(_ filePath: String) -> String {
guard path != "" else {
return filePath
}
guard filePath.hasPrefix(path) else {
return filePath
}
return filePath.replacingOccurrences(of: path + "/", with: "")
}
}
// MARK: Accessing localizations
extension Element {
/**
The full url (relative to root) for the localized page
- Parameter language: The language of the page where the url should point
*/
func fullPageUrl(for language: String) -> String {
localized(for: language).externalUrl ?? localizedPath(for: language)
}
func localized(for language: String) -> LocalizedMetadata {
languages.first { $0.language == language }!
}
func title(for language: String) -> String {
localized(for: language).title
}
/**
Get the back link text for the element.
This text is the one printed for pages of the element, which uses the back text specified by the parent.
*/
func backLinkText(for language: String) -> String {
localized(for: language).parentBackLinkText
}
/**
The optional text to display in a thumbnail corner.
- Note: This text is only displayed for large thumbnails.
*/
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
/**
Returns the full path (relative to the site root for a page of the element in the given language.
*/
func localizedPath(for language: String) -> String {
guard path != "" else {
return Element.htmlPageName(for: language)
}
return path + Element.htmlPagePathAddition(for: language)
}
/**
Get the next language to switch to with the language button.
*/
func nextLanguage(for language: String) -> String? {
let langs = languages.map { $0.language }
guard let index = langs.firstIndex(of: language) else {
return nil
}
for i in 1..<langs.count {
let next = langs[(index + i) % langs.count]
guard hasContent(for: next) else {
continue
}
guard next != language else {
return nil
}
return next
}
return nil
}
func linkPreviewImage(for language: String) -> String? {
localized(for: language).linkPreviewImage ?? thumbnailFileName(for: language)
}
}
// MARK: Page content
extension Element {
var isExternalPage: Bool {
languages.contains { $0.externalUrl != nil }
}
/**
Get the url of the content markdown file for a language.
To check if the file also exists, use `existingContentUrl(for:)`
*/
private func contentUrl(for language: String) -> URL {
inputFolder.appendingPathComponent("\(language).md")
}
/**
Get the url of existing markdown content for a language.
*/
private func existingContentUrl(for language: String) -> URL? {
let url = contentUrl(for: language)
guard url.exists, let size = url.size, size > 0 else {
return nil
}
return url
}
private func hasContent(for language: String) -> Bool {
if !elements.isEmpty {
return true
}
return existingContentUrl(for: language) != nil
}
}
// MARK: Header and Footer
extension Element {
var additionalHeadContentPath: String {
path + "/head.html"
}
var additionalFooterContentPath: String {
path + "/footer.html"
}
}
// MARK: Debug
extension Element {
func printTree(indentation: String = "") {
print(indentation + "/" + path)
elements.forEach { $0.printTree(indentation: indentation + " ") }
}
}
// MARK: Images
extension Element {
struct ManualImage {
let sourcePath: String
let destinationPath: String
let desiredWidth: Int
let desiredHeight: Int?
init?(input: String, path: String, log: MetadataInfoLogger) {
let parts = input.components(separatedBy: " ").filter { !$0.isEmpty }
guard parts.count == 3 || parts.count == 4 else {
log.error("Invalid image specification, expected 'source dest width (height)", source: path)
return nil
}
guard let width = Int(parts[2]) else {
log.error("Invalid width for image \(parts[0])", source: path)
return nil
}
self.sourcePath = Element.relativeToRoot(filePath: parts[0], folder: path)
self.destinationPath = Element.relativeToRoot(filePath: parts[1], folder: path)
self.desiredWidth = width
guard parts.count == 4 else {
self.desiredHeight = nil
return
}
guard let height = Int(parts[3]) else {
log.error("Invalid height for image \(parts[0])", source: path)
return nil
}
self.desiredHeight = height
}
}
}
extension Element {
/**
Find a page by its page ID within the tree of the element.
*/
func find(_ pageId: String) -> Element? {
if self.id == pageId {
return self
}
for child in elements {
if let found = child.find(pageId) {
return found
}
}
return nil
}
var pathComponents: [String] {
path.components(separatedBy: "/")
}
var lastPathComponent: String {
pathComponents.last!
}
func find(elementWithFolder folder: String) -> Element? {
elements.first { $0.lastPathComponent == folder }
}
func makePath(language: String, from root: Element) -> [String] {
let parts = pathComponents.dropLast()
var result = [String]()
var node = root
for part in parts {
guard let child = node.find(elementWithFolder: part) else {
return result
}
result.append(child.title(for: language))
node = child
}
return result
}
func findParent(from root: Element) -> Element? {
var node = root
for part in pathComponents.dropLast() {
guard let child = node.find(elementWithFolder: part) else {
return node
}
node = child
}
return node
}
}
// MARK: Thumbnails
extension Element {
static let defaultThumbnailName = "thumbnail.jpg"
/**
Find the thumbnail for the element.
This function uses either the custom thumbnail path from the metadata or the default name
to find a thumbnail. It first checks if a localized version of the thumbnail exists, or returns the
generic version. If no thumbnail image could be found on disk, then an error is logged and the
generic path is returned.
- Parameter language: The language of the thumbnail
- Returns: The thumbnail (either the localized or the generic version)
*/
func thumbnailFilePath(for language: String) -> (source: String, destination: String) {
let localizedThumbnail = thumbnailPath.insert("-\(language)", beforeLast: ".")
let localizedThumbnailUrl = inputFolder.appendingPathComponent(localizedThumbnail)
if localizedThumbnailUrl.exists {
let source = pathRelativeToRootForContainedInputFile(localizedThumbnail)
let ext = thumbnailPath.lastComponentAfter(".")
let destination = pathRelativeToRootForContainedInputFile("thumbnail-\(language).\(ext)")
return (source, destination)
}
let source = pathRelativeToRootForContainedInputFile(thumbnailPath)
let ext = thumbnailPath.lastComponentAfter(".")
let destination = pathRelativeToRootForContainedInputFile("thumbnail.\(ext)")
return (source, destination)
}
private func thumbnailFileName(for language: String) -> String? {
let localizedThumbnailName = thumbnailPath.insert("-\(language)", beforeLast: ".")
let localizedThumbnail = pathRelativeToRootForContainedInputFile(localizedThumbnailName)
let localizedThumbnailUrl = inputFolder.appendingPathComponent(localizedThumbnail)
if localizedThumbnailUrl.exists {
return localizedThumbnailName
}
let thumbnailUrl = inputFolder.appendingPathComponent(thumbnailPath)
if !thumbnailUrl.exists {
return nil
}
return thumbnailPath
}
}

View File

@ -14,8 +14,8 @@ extension GenericMetadata {
let language: String?
/**
- Note: This field is mandatory
The title used in the page header.
- Note: This field is mandatory
*/
let title: String?
@ -112,6 +112,39 @@ extension GenericMetadata {
It can also be set to manually write a page.
*/
let externalUrl: String?
/**
The text to display for content related to the current page.
This property is mandatory at root level, and is propagated to child elements.
*/
let relatedContentText: String?
/**
The text to display on a navigation element pointing to this element as the previous page.
This property is mandatory at root level, and is propagated to child elements.
*/
let navigationTextAsPreviousPage: String?
/**
The text to display on the navigation element pointing to this element as the next page.
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?
}
}
@ -134,6 +167,11 @@ extension GenericMetadata.LocalizedMetadata: Codable {
.thumbnailSuffix,
.cornerText,
.externalUrl,
.relatedContentText,
.navigationTextAsPreviousPage,
.navigationTextAsNextPage,
.mostRecentTitle,
.featuredTitle,
]
}
@ -163,7 +201,12 @@ extension GenericMetadata.LocalizedMetadata {
titleSuffix: nil,
thumbnailSuffix: nil,
cornerText: nil,
externalUrl: nil)
externalUrl: nil,
relatedContentText: nil,
navigationTextAsPreviousPage: nil,
navigationTextAsNextPage: nil,
mostRecentTitle: nil,
featuredTitle: nil)
}
/**
@ -184,7 +227,12 @@ extension GenericMetadata.LocalizedMetadata {
titleSuffix: nil,
thumbnailSuffix: nil,
cornerText: nil,
externalUrl: nil)
externalUrl: nil,
relatedContentText: "",
navigationTextAsPreviousPage: "",
navigationTextAsNextPage: "",
mostRecentTitle: nil,
featuredTitle: nil)
}
static var full: GenericMetadata.LocalizedMetadata {
@ -202,6 +250,11 @@ extension GenericMetadata.LocalizedMetadata {
titleSuffix: "",
thumbnailSuffix: "",
cornerText: "",
externalUrl: "")
externalUrl: "",
relatedContentText: "",
navigationTextAsPreviousPage: "",
navigationTextAsNextPage: "",
mostRecentTitle: "",
featuredTitle: "")
}
}

View File

@ -77,6 +77,22 @@ struct GenericMetadata {
*/
let requiredFiles: Set<String>?
/**
Additional images required by the element.
These images are specified as: `source_name destination_name width (height)`.
*/
let images: Set<String>?
/**
The path to the thumbnail file.
This property is optional, and defaults to ``Element.defaultThumbnailName``.
Note: The generator first looks for localized versions of the thumbnail by appending `-[lang]` to the file name,
e.g. `customThumb-en.jpg`. If no file is found, then the specified file is tried.
*/
let thumbnailPath: String?
/**
The style of thumbnail to use when generating overviews.
@ -101,12 +117,26 @@ struct GenericMetadata {
let overviewItemCount: Int?
/**
Indicate that no header should be generated automatically.
Indicate the header type to be generated automatically.
This option assumes that custom header code is present in the page source files
- Note: If not specified, this property defaults to `false`.
If this option is set to `none`, then custom header code should be present in the page source files
- Note: If not specified, this property defaults to `left`.
- Note: Overview pages are always using `center`.
*/
let useCustomHeader: Bool?
let headerType: String?
/**
Indicate that the overview section should contain a `Newest Content` section before the other sections.
- Note: If not specified, this property defaults to `false`
*/
let showMostRecentSection: Bool?
/**
Indicate that the overview section should contain a `Featured Content` section before the other sections.
The elements are the page ids of the elements contained in the feature.
- Note: If not specified, this property defaults to `false`
*/
let featuredPages: [String]?
/**
The localized metadata for each language.
@ -127,10 +157,14 @@ extension GenericMetadata: Codable {
.sortIndex,
.externalFiles,
.requiredFiles,
.images,
.thumbnailPath,
.thumbnailStyle,
.useManualSorting,
.overviewItemCount,
.useCustomHeader,
.headerType,
.showMostRecentSection,
.featuredPages,
.languages,
]
}
@ -150,8 +184,8 @@ extension GenericMetadata {
- Note: The decoding routine also checks for unknown properties, and writes them to the output.
- Note: Uses global objects
*/
init?(source: String) {
guard let data = files.dataOfOptionalFile(atPath: source, source: source) else {
init?(source: String, log: MetadataInfoLogger) {
guard let data = log.readPotentialMetadata(atPath: source, source: source) else {
return nil
}
@ -177,8 +211,7 @@ extension GenericMetadata {
do {
self = try decoder.decode(from: data)
} catch {
print("Here \(data)")
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
log.failedToDecodeMetadata(source: source, error: error)
return nil
}
}
@ -197,10 +230,14 @@ extension GenericMetadata {
sortIndex: 1,
externalFiles: [],
requiredFiles: [],
images: [],
thumbnailPath: "",
thumbnailStyle: "",
useManualSorting: false,
overviewItemCount: 6,
useCustomHeader: false,
headerType: "left",
showMostRecentSection: false,
featuredPages: [],
languages: [.full])
}
}

View File

@ -0,0 +1,35 @@
import Foundation
enum HeaderType: String {
/**
The standard page header, left-aligned
*/
case left
/**
The standard overview header, centered
*/
case center
/**
The element provides it's own header, so don't generate any.
*/
case none
}
extension HeaderType: StringProperty {
init?(_ value: String) {
self.init(rawValue: value)
}
static var castFailureReason: String {
"Header type must be 'left', 'center' or 'none'"
}
}
extension HeaderType: DefaultValueProvider {
static var defaultValue: HeaderType { .left }
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,13 @@
import Foundation
import CryptoKit
extension Digest {
var bytes: [UInt8] { Array(makeIterator()) }
var data: Data { Data(bytes) }
var hexStr: String {
bytes.map { String(format: "%02X", $0) }.joined()
}
}

View File

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

View File

@ -7,8 +7,8 @@ extension NSImage {
guard self.size.width > size.width else {
return self
}
return NSImage(size: size, flipped: false) { (resizedRect) -> Bool in
self.draw(in: resizedRect)
return NSImage(size: size, flipped: false) { [weak self] (resizedRect) -> Bool in
self?.draw(in: resizedRect)
return true
}
}

View File

@ -12,7 +12,7 @@ extension NSSize {
return self
}
let height = height * desiredWidth / width
let height = (height * desiredWidth / width).rounded(.down)
return NSSize(width: desiredWidth, height: height)
}
}

View File

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

View File

@ -20,6 +20,11 @@ extension String {
.joined(separator: "\n")
}
/**
Remove the part after the last occurence of the separator (including the separator itself).
The string is left unchanges, if it does not contain the separator.
*/
func dropAfterLast(_ separator: String) -> String {
guard contains(separator) else {
return self
@ -51,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!
}
@ -62,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 {
@ -74,3 +99,8 @@ extension String {
try data(using: .utf8)!.createFolderAndWrite(to: url)
}
}
extension String: DefaultValueProvider {
static var defaultValue: String { "" }
}

View File

@ -0,0 +1,72 @@
import Foundation
extension URL {
func ensureParentFolderExistence() throws {
try deletingLastPathComponent().ensureFolderExistence()
}
func ensureFolderExistence() throws {
guard !exists else {
return
}
try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true)
}
var isDirectory: Bool {
do {
let resources = try resourceValues(forKeys: [.isDirectoryKey])
guard let isDirectory = resources.isDirectory else {
print("No isDirectory info for \(path)")
return false
}
return isDirectory
} catch {
print("Failed to get directory information from \(path): \(error)")
return false
}
}
var exists: Bool {
FileManager.default.fileExists(atPath: path)
}
/**
Delete the file at the url.
*/
func delete() throws {
try FileManager.default.removeItem(at: self)
}
func copy(to url: URL) throws {
if url.exists {
try url.delete()
}
try url.ensureParentFolderExistence()
try FileManager.default.copyItem(at: self, to: url)
}
var size: Int? {
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

@ -0,0 +1,91 @@
import Foundation
/**
The global configuration of the website.
*/
struct Configuration: Codable {
/**
The width of page content in pixels.
The width specification is used to scale images to the correct width,
when images are included in markdown content using the syntax
`![](image.jpg)`.
- Note: A high-resolution `@2x` version will be generated as well.
*/
let pageImageWidth: Int
/**
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 `clean-css` tool,
which can be installed using the `install.sh` script in the root folder of the generator.
*/
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.
*/
var contentPath: String
/**
The path where the generated website should be written.
*/
var outputPath: String
/**
Create .md files for content pages, if they don't exist.
After the languages of the root element are read, the generator looks
for localized `.md` files for each page element which has metadata.
If it can't find a content file, it generates a placeholder.
Setting this option to `true` will cause the generator to create empty `.md`
files for each root level language. This can be helpful to see which content still needs
to be written. There is then also no need to manually create these files.
- Note: Empty content files will continue to be ignored by the generator,
and treated as if they are not present.
*/
let createMdFilesIfMissing: Bool
var contentDirectory: URL {
.init(fileURLWithPath: contentPath)
}
var outputDirectory: URL {
.init(fileURLWithPath: outputPath)
}
mutating func adjustPathsRelative(to folder: URL) {
if !contentPath.hasPrefix("/") {
contentPath = folder.appendingPathComponent(contentPath).resolvingFolderTraversal()!.path
}
if !outputPath.hasPrefix("/") {
outputPath = folder.appendingPathComponent(outputPath).resolvingFolderTraversal()!.path
}
}
func printOverview() {
print(" Source folder: \(contentDirectory.path)")
print(" Output folder: \(outputDirectory.path)")
print(" Page width: \(pageImageWidth)")
print(" Full-screen width: \(fullScreenImageWidth)")
print(" Minify JavaScript: \(minifyJavaScript)")
print(" Minify CSS: \(minifyCSS)")
print(" Create markdown files: \(createMdFilesIfMissing)")
}
}

View File

@ -0,0 +1,92 @@
import Foundation
import CryptoKit
final class FileUpdateChecker {
private let hashesFileName = "hashes.json"
private let input: URL
/**
The hashes of all accessed files from the previous run
The key is the relative path to the file from the source
*/
private var previousFiles: [String : Data] = [:]
/**
The paths of all files which were accessed, with their new hashes
This list is used to check if a file was modified, and to write all accessed files back to disk
*/
private var accessedFiles: [String : Data] = [:]
var numberOfFilesLoaded: Int {
previousFiles.count
}
var numberOfFilesAccessed: Int {
accessedFiles.count
}
init(input: URL) {
self.input = input
}
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: url)
} catch {
return .failed("Failed to read hashes from last run: \(error)")
}
do {
self.previousFiles = try JSONDecoder().decode(from: data)
} catch {
return .failed("Failed to decode hashes from last run: \(error)")
}
return .loaded
}
func fileHasChanged(at path: String) -> Bool {
guard let oldHash = previousFiles[path] else {
// Image wasn't used last time, so treat as new
return true
}
guard let newHash = accessedFiles[path] else {
// Each image should have been loaded once
// before using this function
fatalError()
}
return oldHash != newHash
}
func didLoad(_ data: Data, at path: String) {
accessedFiles[path] = SHA256.hash(data: data).data
}
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: url)
return nil
} catch {
return "Failed to save file hashes: \(error)"
}
}
}
var notFound = 0

View File

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

View File

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

View File

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

View File

@ -0,0 +1,52 @@
import Foundation
final class ValidationLog {
private enum LogLevel: String {
case error = "ERROR"
case warning = "WARNING"
case info = "INFO"
}
init() {
}
private func add(_ type: LogLevel, item: ContentError) {
let errorText: String
if let err = item.error {
errorText = ", Error: \(err.localizedDescription)"
} else {
errorText = ""
}
print("[\(type.rawValue)][\(item.source)] \(item.reason)\(errorText)")
}
func add(error: ContentError) {
add(.error, item: error)
}
func add(error reason: String, source: String, error: Error? = nil) {
add(error: .init(reason: reason, source: source, error: error))
}
func add(warning: ContentError) {
add(.warning, item: warning)
}
func add(warning reason: String, source: String, error: Error? = nil) {
add(warning: .init(reason: reason, source: source, error: error))
}
func add(info: ContentError) {
add(.info, item: info)
}
func add(info reason: String, source: String, error: Error? = nil) {
add(info: .init(reason: reason, source: source, error: error))
}
func failedToOpen(_ file: String, requiredBy source: String, error: Error?) {
print("[ERROR] Failed to open file '\(file)' required by \(source): \(error?.localizedDescription ?? "No error provided")")
}
}

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,18 +1,18 @@
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>"
}
// - TODO: Make link relative
func topBarWebsiteTitle(language: String) -> String {
Element.htmlPagePathAddition(for: language)
func topBarWebsiteTitle(language: String, from page: Element) -> String {
guard let pathToRoot = page.pathToRoot else {
return Element.htmlPageName(for: language)
}
return pathToRoot + Element.htmlPagePathAddition(for: language)
}
func topBarLanguageButton(_ language: String) -> String {
@ -35,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 {
@ -101,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)
@ -21,15 +23,14 @@ struct OverviewPageGenerator {
let languageButton = section.nextLanguage(for: language)
content[.topBar] = factory.topBar.generate(
sectionUrl: section.sectionUrl(for: language),
languageButton: languageButton)
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
}
log.add(info: "Page generated", source: 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

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

View File

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

View File

@ -0,0 +1,100 @@
import Foundation
import Ink
struct PageGenerator {
private let factory: LocalizedSiteTemplate
private let contentGenerator: PageContentGenerator
private let results: GenerationResultsHandler
init(factory: LocalizedSiteTemplate, siteRoot: Element, results: GenerationResultsHandler) {
self.factory = factory
self.results = results
self.contentGenerator = PageContentGenerator(factory: factory.factory, siteRoot: siteRoot, results: results)
}
func generate(page: Element, language: String, previousPage: Element?, nextPage: Element?) {
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, additionalHeaders) = makeContent(
page: page, metadata: metadata, language: language, path: inputContentPath)
var content = [PageTemplate.Key : String]()
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"
content[.header] = makeHeader(page: page, metadata: metadata, language: language)
content[.content] = pageContent
content[.previousPageLinkText] = previousText(for: previousPage, language: language)
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] = results.getContentOfOptionalFile(at: page.additionalFooterContentPath, source: page.path)
if additionalHeaders.contains(.codeHightlighting) {
let highlightCode = factory.factory.html.codeHighlightFooter()
content[.footer] = (content[.footer].unwrapped { $0 + "\n" } ?? "") + highlightCode
}
if page.state == .draft {
results.markPageAsDraft(page: page.path)
}
factory.page.generate(content, to: path, source: page.path)
}
private func navLink(from element: Element, to destination: Element?, language: String) -> String? {
guard let fullPath = destination?.fullPageUrl(for: language) else {
return nil
}
return element.relativePathToOtherSiteElement(file: fullPath)
}
private func previousText(for element: Element?, language: String) -> String? {
guard let text = element?.localized(for: language).navigationTextAsPreviousPage else {
return nil
}
return factory.factory.html.makePrevText(text)
}
private func nextText(for element: Element?, language: String) -> String? {
guard let text = element?.localized(for: language).navigationTextAsNextPage else {
return nil
}
return factory.factory.html.makeNextText(text)
}
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)
} 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)
}
}
private func makeHeader(page: Element, metadata: Element.LocalizedMetadata, language: String) -> String? {
let content = factory.makeHeaderContent(page: page, metadata: metadata, language: language)
switch page.headerType {
case .none:
return nil
case .left:
return factory.factory.leftHeader.generate(content)
case .center:
return factory.factory.centeredHeader.generate(content)
}
}
}

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]()
@ -21,27 +25,27 @@ struct PageHeadGenerator {
// Note: Generate separate destination link for the image,
// since we don't want a single large image for thumbnails.
// Warning: Link preview source path must be relative to root
let linkPreviewImageName = image.insert("-link", beforeLast: ".")
let linkPreviewImageName = "thumbnail-link.\(image.lastComponentAfter("."))"
let sourceImagePath = page.pathRelativeToRootForContainedInputFile(image)
let destinationImagePath = page.pathRelativeToRootForContainedInputFile(linkPreviewImageName)
files.requireImage(
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"
#warning("Make highlight script path relative")
let includeText = factory.html.scriptInclude(path: scriptPath)
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

@ -0,0 +1,34 @@
import Foundation
enum ShorthandMarkdownKey: String {
/**
A variable number of download buttons for file downloads
*/
case downloadButtons = "download"
/**
A large button to an external page.
*/
case externalLink = "external"
/**
Additional HTML code include verbatim into the page.
*/
case includedHtml = "html"
/**
A box with a heading and a text description
*/
case box = "box"
/**
A pretty link to another page on the site.
*/
case pageLink = "page"
/**
A 3D model to display
*/
case model3d = "3d"
}

View File

@ -0,0 +1,65 @@
import Foundation
typealias LinkedElement = (previous: Element?, element: Element, next: Element?)
struct SiteGenerator {
let templates: TemplateFactory
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) {
site.languages.forEach {
generate(site: site, metadata: $0)
}
}
private func generate(site: Element, metadata: Element.LocalizedMetadata) {
let language = metadata.language
let template = LocalizedSiteTemplate(
factory: templates,
language: language,
site: site,
results: results)
// Generate sections
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() {
// Move recursively down to all pages
elementsToProcess.append(contentsOf: element.linkedElements)
processAllFiles(for: element)
if element.hasVisibleChildren {
overviewGenerator.generate(section: element, language: language)
} else {
pageGenerator.generate(
page: element,
language: language,
previousPage: previous,
nextPage: next)
}
}
}
private func processAllFiles(for element: Element) {
element.externalFiles.forEach { results.exclude(file: $0, source: element.path) }
element.requiredFiles.forEach { results.require(file: $0, source: element.path) }
element.images.forEach {
results.requireSingleImage(
source: $0.sourcePath,
destination: $0.destinationPath,
requiredBy: element.path,
width: $0.desiredWidth,
desiredHeight: $0.desiredHeight)
}
}
}

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 {
@ -14,42 +17,37 @@ struct ThumbnailListGenerator {
}
private func itemContent(_ item: Element, parent: Element, language: String, style: ThumbnailStyle) -> String {
let fullThumbnailPath = item.thumbnailFilePath(for: language)
let relativeImageUrl = parent.relativePathToFileWithPath(fullThumbnailPath)
let metadata = item.localized(for: language)
var content = [ThumbnailKey : String]()
if item.state.hasThumbnailLink {
#warning("If page in language is missing, link to different language")
let fullPageUrl = item.fullPageUrl(for: language)
let relativePageUrl = parent.relativePathToFileWithPath(fullPageUrl)
content[.url] = "href=\"\(relativePageUrl)\""
}
content[.image] = relativeImageUrl
let (thumbnailSourcePath, thumbnailDestPath) = item.thumbnailFilePath(for: language)
let thumbnailDestNoExtension = thumbnailDestPath.dropAfterLast(".")
content[.image] = parent.relativePathToFileWithPath(thumbnailDestNoExtension)
content[.altText] = metadata.linkPreviewDescription
if style == .large, let suffix = metadata.thumbnailSuffix {
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
} else {
content[.title] = metadata.title
}
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
content[.corner] = item.cornerText(for: language).unwrapped {
factory.largeThumbnail.makeCorner(text: $0)
}
files.requireImage(
source: fullThumbnailPath,
destination: fullThumbnailPath,
results.requireMultiVersionImage(
source: thumbnailSourcePath,
destination: thumbnailDestNoExtension + ".jpg",
requiredBy: item.path,
width: style.width,
desiredHeight: style.height)
// Create image version for high-resolution screens
files.requireImage(
source: fullThumbnailPath,
destination: fullThumbnailPath.insert("@2x", beforeLast: "."),
width: style.width * 2,
desiredHeight: style.height * 2)
return factory.thumbnail(style: style).generate(content, shouldIndent: false)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,188 @@
import Foundation
final class MetadataInfoLogger {
private let input: URL
private var numberOfMetadataFiles = 0
private var unusedProperties: [(name: String, source: String)] = []
private var invalidProperties: [(name: String, source: String, reason: String)] = []
private var unknownProperties: [(name: String, source: String)] = []
private var missingProperties: [(name: String, source: String)] = []
private var unreadableMetadata: [(source: String, error: Error)] = []
private var warnings: [(source: String, message: String)] = []
private var errors: [(source: String, message: String)] = []
init(input: URL) {
self.input = input
}
/**
Adds an info message if a value is set for an unused property.
- Note: Unused properties do not cause an element to be skipped.
*/
@discardableResult
func unused<T>(_ value: Optional<T>, _ name: String, source: String) -> T where T: DefaultValueProvider {
if let value {
unusedProperties.append((name, source))
return value
}
return T.defaultValue
}
/**
Cast a string value to another value, and using a default in case of errors.
- Note: Invalid string values do not cause an element to be skipped.
*/
func cast<T>(_ value: String, _ name: String, source: String) -> T where T: DefaultValueProvider, T: StringProperty {
guard let result = T.init(value) else {
invalidProperties.append((name: name, source: source, reason: T.castFailureReason))
return T.defaultValue
}
return result
}
/**
Cast a string value to another value, and using a default in case of errors or missing values.
- Note: Invalid string values do not cause an element to be skipped.
*/
func cast<T>(_ value: String?, _ name: String, source: String) -> T where T: DefaultValueProvider, T: StringProperty {
guard let value else {
return T.defaultValue
}
guard let result = T.init(value) else {
invalidProperties.append((name: name, source: source, reason: T.castFailureReason))
return T.defaultValue
}
return result
}
/**
Cast the string value of an unused property to another value, and using a default in case of errors.
- Note: Invalid string values do not cause an element to be skipped.
*/
func castUnused<R>(_ value: String?, _ name: String, source: String) -> R where R: DefaultValueProvider, R: StringProperty {
unused(value.unwrapped { cast($0, name, source: source) }, name, source: source)
}
/**
Note an unknown property.
- Note: Unknown properties do not cause an element to be skipped.
*/
func unknown(property: String, source: String) {
unknownProperties.append((name: property, source: source))
}
/**
Ensure that a property is set, and aborting metadata decoding.
- Note: Missing required properties cause an element to be skipped.
*/
func required<T>(_ value: T?, name: String, source: String, _ valid: inout Bool) -> T where T: DefaultValueProvider {
guard let value = value else {
missingProperties.append((name, source))
valid = false
return T.defaultValue
}
return value
}
func warning(_ message: String, source: String) {
warnings.append((source, message))
}
func error(_ message: String, source: String) {
errors.append((source, message))
}
func failedToDecodeMetadata(source: String, error: Error) {
unreadableMetadata.append((source, error))
}
func readPotentialMetadata(atPath path: String, source: String) -> Data? {
let url = input.appendingPathComponent(path)
guard url.exists else {
return nil
}
numberOfMetadataFiles += 1
printMetadataScanUpdate()
do {
return try Data(contentsOf: url)
} catch {
unreadableMetadata.append((source, error))
return nil
}
}
// MARK: Printing
private func printMetadataScanUpdate() {
print(" Pages found: \(numberOfMetadataFiles) \r", terminator: "")
}
func printMetadataScanOverview(languages: Int) {
var notes = [String]()
func addIfNotZero<S>(_ sequence: Array<S>, _ name: String) {
guard sequence.count > 0 else {
return
}
notes.append("\(sequence.count) \(name)")
}
addIfNotZero(warnings, "warnings")
addIfNotZero(errors, "errors")
addIfNotZero(unreadableMetadata, "unreadable files")
addIfNotZero(unusedProperties, "unused properties")
addIfNotZero(invalidProperties, "invalid properties")
addIfNotZero(unknownProperties, "unknown properties")
addIfNotZero(missingProperties, "missing properties")
print(" Pages found: \(numberOfMetadataFiles) ")
print(" Languages: \(languages)")
if !notes.isEmpty {
print(" Notes: " + notes.joined(separator: ", "))
}
}
func writeResults(to file: URL) {
guard !errors.isEmpty || !warnings.isEmpty || !unreadableMetadata.isEmpty || !unusedProperties.isEmpty || !invalidProperties.isEmpty || !unknownProperties.isEmpty || !missingProperties.isEmpty else {
do {
if FileManager.default.fileExists(atPath: file.path) {
try FileManager.default.removeItem(at: file)
}
} catch {
print(" Failed to delete metadata log: \(error)")
}
return
}
var lines: [String] = []
func add<S>(_ name: String, _ property: S, convert: (S.Element) -> String) where S: Sequence {
let elements = property.map { " " + convert($0) }.sorted()
guard !elements.isEmpty else {
return
}
lines.append("\(name):")
lines.append(contentsOf: elements)
}
add("Errors", errors) { "\($0.source): \($0.message)" }
add("Warnings", warnings) { "\($0.source): \($0.message)" }
add("Unreadable files", unreadableMetadata) { "\($0.source): \($0.error)" }
add("Unused properties", unusedProperties) { "\($0.source): \($0.name)" }
add("Invalid properties", invalidProperties) { "\($0.source): \($0.name) (\($0.reason))" }
add("Unknown properties", unknownProperties) { "\($0.source): \($0.name)" }
add("Missing properties", missingProperties) { "\($0.source): \($0.name)" }
let data = lines.joined(separator: "\n").data(using: .utf8)!
do {
try data.createFolderAndWrite(to: file)
} catch {
print(" Failed to save metadata log: \(error)")
}
}
}

View File

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

View File

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

View File

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

View File

@ -1,13 +1,15 @@
import Foundation
struct PlaceholderTemplate: Template {
struct BoxTemplate: Template {
enum Key: String, CaseIterable {
case title = "TITLE"
case text = "TEXT"
}
static let templateName = "empty.html"
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

@ -0,0 +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 {
typealias Key = PageImageTemplateKey
static let templateName = "image.html"
let raw: String
let results: GenerationResultsHandler
}

View File

@ -0,0 +1,24 @@
import Foundation
struct PageLinkTemplate: Template {
enum Key: String, CaseIterable {
case altText = "ALT_TEXT"
case url = "URL"
case image = "IMAGE"
case title = "TITLE"
case path = "PATH"
case description = "DESCRIPTION"
case className = "CLASS"
}
static let templateName = "page-link.html"
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,9 +6,9 @@ protocol ThumbnailTemplate {
}
enum ThumbnailKey: String, CaseIterable {
case altText = "ALT_TEXT"
case url = "URL"
case image = "IMAGE"
case image2x = "IMAGE_2X"
case title = "TITLE"
case corner = "CORNER"
}
@ -21,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>"
}
@ -37,6 +39,8 @@ struct SquareThumbnailTemplate: Template, ThumbnailTemplate {
static let templateName = "thumbnail-square.html"
let raw: String
let results: GenerationResultsHandler
}
struct SmallThumbnailTemplate: Template, ThumbnailTemplate {
@ -46,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) throws {
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
@ -61,23 +61,19 @@ struct LocalizedSiteTemplate {
url: $0.path + Element.htmlPagePathAddition(for: language))
}
self.topBar = try .init(
self.topBar = .init(
factory: factory,
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
func makePlaceholder(metadata: Element.LocalizedMetadata) -> String {
factory.placeholder.generate([
.title: metadata.placeholderTitle,
.text: metadata.placeholderText])
factory.makePlaceholder(title: metadata.placeholderTitle, text: metadata.placeholderText)
}
func makeBackLink(text: String, language: String) -> String {

View File

@ -10,17 +10,17 @@ struct PrefilledTopBarTemplate {
private let factory: TemplateFactory
init(factory: TemplateFactory, language: String, sections: [SectionInfo], topBarWebsiteTitle: String) throws {
init(factory: TemplateFactory, language: String, sections: [SectionInfo], topBarWebsiteTitle: String) {
self.factory = factory
self.language = language
self.sections = sections
self.topBarWebsiteTitle = topBarWebsiteTitle
}
func generate(sectionUrl: String?, languageButton: String?) -> String {
func generate(sectionUrl: String?, languageButton: String?, page: Element) -> String {
var content = [TopBarTemplate.Key : String]()
content[.title] = topBarWebsiteTitle
content[.titleLink] = factory.html.topBarWebsiteTitle(language: language)
content[.titleLink] = factory.html.topBarWebsiteTitle(language: language, from: page)
content[.elements] = elements(activeSectionUrl: sectionUrl)
content[.languageButton] = languageButton.unwrapped(factory.html.topBarLanguageButton) ?? ""
return factory.topBar.generate(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 {
init(from url: URL, results: GenerationResultsHandler) throws {
do {
let raw = try String(contentsOf: url)
self.init(raw: raw)
self.init(raw: raw, results: results)
} catch {
results.warning("Failed to load: \(error)", source: "Template \(url.lastPathComponent)")
throw error
}
}
func generate(_ content: [Key : String], to url: URL) -> Bool {
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

@ -0,0 +1,106 @@
import Foundation
final class TemplateFactory {
let templateFolder: URL
// MARK: Site Elements
let backNavigation: BackNavigationTemplate
let pageHead: PageHeadTemplate
let topBar: TopBarTemplate
let overviewSection: OverviewSectionTemplate
let overviewSectionClean: OverviewSectionCleanTemplate
let pageLink: PageLinkTemplate
let box: BoxTemplate
// MARK: Thumbnails
let largeThumbnail: LargeThumbnailTemplate
let squareThumbnail: SquareThumbnailTemplate
let smallThumbnail: SmallThumbnailTemplate
func thumbnail(style: ThumbnailStyle) -> ThumbnailTemplate {
switch style {
case .large:
return largeThumbnail
case .square:
return squareThumbnail
case .small:
return smallThumbnail
}
}
// MARK: Headers
let leftHeader: LeftHeaderTemplate
let centeredHeader: CenteredHeaderTemplate
// MARK: Pages
let page: PageTemplate
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, results: GenerationResultsHandler) throws {
self.templateFolder = 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()
}
// MARK: Convenience methods
func makePlaceholder(title: String, text: String) -> String {
box.generate([
.title: title,
.text: text])
}
}

187
Sources/Generator/run.swift Normal file
View File

@ -0,0 +1,187 @@
import Foundation
import ArgumentParser
@main
struct CHGenerator: ParsableCommand {
@Argument(help: "The path to the generator configuration file")
var configPath: String
mutating func run() throws {
try generate(configPath: configPath)
}
}
private func loadConfiguration(at configPath: String) -> Configuration? {
print("--- CONFIGURATION ----------------------------------")
print(" ")
print(" Configuration file: \(configPath)")
let configUrl = URL(fileURLWithPath: configPath)
guard configUrl.exists else {
print(" Error: Configuration file not found")
return nil
}
var config: Configuration
do {
let data = try Data(contentsOf: configUrl)
config = try JSONDecoder().decode(from: data)
config.adjustPathsRelative(to: configUrl.deletingLastPathComponent())
} catch {
print(" Configuration error: \(error)")
return nil
}
config.printOverview()
print(" ")
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
}
let runFolder = configuration.contentDirectory.appendingPathComponent("run")
// 2. Scan site elements
guard let (siteRoot, pageMap) = try loadSiteData(in: configuration.contentDirectory, runFolder: runFolder) else {
return
}
let fileUpdates = FileUpdateChecker(input: configuration.contentDirectory)
switch fileUpdates.loadPreviousRun(from: runFolder) {
case .notLoaded:
print("Regarding all files as new (no hashes loaded)")
case .loaded:
break
case .failed(let error):
print("Regarding all files as new (\(error))")
}
// 3. Generate pages
guard let (images, files) = generatePages(from: siteRoot, configuration: configuration, fileUpdates: fileUpdates, pageMap: pageMap, runFolder: runFolder) else {
return
}
// 4. Generate images
generateImages(images, configuration: configuration, runFolder: runFolder, fileUpdates: fileUpdates)
// 5. Copy/minify files
copyFiles(files: files, configuration: configuration, runFolder: runFolder)
complete = true
}

View File

@ -1,573 +0,0 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 55;
objects = {
/* Begin PBXBuildFile section */
E22E8763289D84C300E51191 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8762289D84C300E51191 /* main.swift */; };
E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E876B289D855D00E51191 /* ThumbnailStyle.swift */; };
E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */; };
E22E877D289DBA0A00E51191 /* OverviewSectionGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */; };
E22E878C289E4A8900E51191 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = E22E878B289E4A8900E51191 /* Ink */; };
E22E8795289E81D700E51191 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8794289E81D700E51191 /* URL+Extensions.swift */; };
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879A289EE02F00E51191 /* Optional+Extensions.swift */; };
E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */; };
E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */; };
E22E87A4289F0C7000E51191 /* SiteGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87A3289F0C7000E51191 /* SiteGenerator.swift */; };
E22E87A8289F0E7B00E51191 /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87A7289F0E7B00E51191 /* PageGenerator.swift */; };
E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */; };
E22E87AC289F1D3700E51191 /* Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AB289F1D3700E51191 /* Template.swift */; };
E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AD289F1E0000E51191 /* String+Extensions.swift */; };
E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */; };
E253C87728B767D50076B6D0 /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87628B767D50076B6D0 /* ImageType.swift */; };
E253C87A28B810090076B6D0 /* ImageOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87928B810090076B6D0 /* ImageOutput.swift */; };
E253C87C28B8BFB80076B6D0 /* FileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87B28B8BFB80076B6D0 /* FileSystem.swift */; };
E253C87F28B8FBB00076B6D0 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */; };
E253C88128B8FBFF0076B6D0 /* NSSize+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */; };
E253C88328B8FC470076B6D0 /* NSImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */; };
E253C88528BA32FB0076B6D0 /* HTMLElementsGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C88428BA32FB0076B6D0 /* HTMLElementsGenerator.swift */; };
E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */; };
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D628A022C500102A25 /* TemplateFactory.swift */; };
E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */; };
E2C5A5DB28A02F9000102A25 /* TopBarTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */; };
E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5DC28A036BE00102A25 /* OverviewSectionTemplate.swift */; };
E2C5A5E128A0373300102A25 /* ThumbnailTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */; };
E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E228A037F900102A25 /* PageTemplate.swift */; };
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */; };
E2C5A5E928A0451C00102A25 /* LocalizedSiteTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */; };
E2D4225128BD242200400E64 /* Configuration.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D4225028BD242200400E64 /* Configuration.swift */; };
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */; };
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */; };
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */; };
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */; };
E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */; };
E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2728ACD84400632026 /* VideoType.swift */; };
E2F8FA2B28AD0BD200632026 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = E2F8FA2A28AD0BD200632026 /* Splash */; };
E2F8FA2D28AD2F5300632026 /* GenericMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */; };
E2F8FA3028AD450B00632026 /* PageState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2F28AD450B00632026 /* PageState.swift */; };
E2F8FA3228AD456C00632026 /* GenericMetadata+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */; };
E2F8FA3428AD6F3400632026 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3328AD6F3400632026 /* Element.swift */; };
E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */; };
E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3728AE27A500632026 /* ContentError.swift */; };
E2F8FA3A28AE313A00632026 /* ValidationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3928AE313A00632026 /* ValidationLog.swift */; };
E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */; };
/* End PBXBuildFile section */
/* Begin PBXCopyFilesBuildPhase section */
E22E875D289D84C300E51191 /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = /usr/share/man/man1/;
dstSubfolderSpec = 0;
files = (
);
runOnlyForDeploymentPostprocessing = 1;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
E22E875F289D84C300E51191 /* WebsiteGenerator */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = WebsiteGenerator; sourceTree = BUILT_PRODUCTS_DIR; };
E22E8762289D84C300E51191 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailStyle.swift; sourceTree = "<group>"; };
E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexPageGenerator.swift; sourceTree = "<group>"; };
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionGenerator.swift; sourceTree = "<group>"; };
E22E8794289E81D700E51191 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = "<group>"; };
E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageGenerator.swift; sourceTree = "<group>"; };
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailListGenerator.swift; sourceTree = "<group>"; };
E22E87A3289F0C7000E51191 /* SiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteGenerator.swift; sourceTree = "<group>"; };
E22E87A7289F0E7B00E51191 /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = "<group>"; };
E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadGenerator.swift; sourceTree = "<group>"; };
E22E87AB289F1D3700E51191 /* Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template.swift; sourceTree = "<group>"; };
E22E87AD289F1E0000E51191 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefilledTopBarTemplate.swift; sourceTree = "<group>"; };
E253C87628B767D50076B6D0 /* ImageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageType.swift; sourceTree = "<group>"; };
E253C87928B810090076B6D0 /* ImageOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageOutput.swift; sourceTree = "<group>"; };
E253C87B28B8BFB80076B6D0 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = "<group>"; };
E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+Extensions.swift"; sourceTree = "<group>"; };
E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extensions.swift"; sourceTree = "<group>"; };
E253C88428BA32FB0076B6D0 /* HTMLElementsGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLElementsGenerator.swift; sourceTree = "<group>"; };
E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderTemplate.swift; sourceTree = "<group>"; };
E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = "<group>"; };
E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadTemplate.swift; sourceTree = "<group>"; };
E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBarTemplate.swift; sourceTree = "<group>"; };
E2C5A5DC28A036BE00102A25 /* OverviewSectionTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionTemplate.swift; sourceTree = "<group>"; };
E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailTemplate.swift; sourceTree = "<group>"; };
E2C5A5E228A037F900102A25 /* PageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTemplate.swift; sourceTree = "<group>"; };
E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationTemplate.swift; sourceTree = "<group>"; };
E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSiteTemplate.swift; sourceTree = "<group>"; };
E2D4225028BD242200400E64 /* Configuration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionCleanTemplate.swift; sourceTree = "<group>"; };
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = "<group>"; };
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = "<group>"; };
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = "<group>"; };
E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageVideoTemplate.swift; sourceTree = "<group>"; };
E2F8FA2728ACD84400632026 /* VideoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoType.swift; sourceTree = "<group>"; };
E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMetadata.swift; sourceTree = "<group>"; };
E2F8FA2F28AD450B00632026 /* PageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageState.swift; sourceTree = "<group>"; };
E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GenericMetadata+Localized.swift"; sourceTree = "<group>"; };
E2F8FA3328AD6F3400632026 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = "<group>"; };
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Element+LocalizedMetadata.swift"; sourceTree = "<group>"; };
E2F8FA3728AE27A500632026 /* ContentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentError.swift; sourceTree = "<group>"; };
E2F8FA3928AE313A00632026 /* ValidationLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationLog.swift; sourceTree = "<group>"; };
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decodable+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
E22E875C289D84C300E51191 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E22E878C289E4A8900E51191 /* Ink in Frameworks */,
E2F8FA2B28AD0BD200632026 /* Splash in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
E22E8756289D84C300E51191 = {
isa = PBXGroup;
children = (
E22E8761289D84C300E51191 /* WebsiteGenerator */,
E22E8760289D84C300E51191 /* Products */,
);
sourceTree = "<group>";
};
E22E8760289D84C300E51191 /* Products */ = {
isa = PBXGroup;
children = (
E22E875F289D84C300E51191 /* WebsiteGenerator */,
);
name = Products;
sourceTree = "<group>";
};
E22E8761289D84C300E51191 /* WebsiteGenerator */ = {
isa = PBXGroup;
children = (
E22E8762289D84C300E51191 /* main.swift */,
E253C87828B80AAF0076B6D0 /* Files */,
E2F8FA2E28AD44FF00632026 /* Content */,
E22E87A2289F0C6200E51191 /* Generators */,
E2C5A5D328A0222B00102A25 /* Templates */,
E22E8799289EE02300E51191 /* Extensions */,
);
path = WebsiteGenerator;
sourceTree = "<group>";
};
E22E8799289EE02300E51191 /* Extensions */ = {
isa = PBXGroup;
children = (
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */,
E22E87AD289F1E0000E51191 /* String+Extensions.swift */,
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */,
E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */,
E22E8794289E81D700E51191 /* URL+Extensions.swift */,
E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */,
E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
E22E87A2289F0C6200E51191 /* Generators */ = {
isa = PBXGroup;
children = (
E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */,
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */,
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */,
E22E87A3289F0C7000E51191 /* SiteGenerator.swift */,
E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */,
E22E87A7289F0E7B00E51191 /* PageGenerator.swift */,
E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */,
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */,
E253C88428BA32FB0076B6D0 /* HTMLElementsGenerator.swift */,
);
path = Generators;
sourceTree = "<group>";
};
E253C87828B80AAF0076B6D0 /* Files */ = {
isa = PBXGroup;
children = (
E2F8FA3728AE27A500632026 /* ContentError.swift */,
E2F8FA3928AE313A00632026 /* ValidationLog.swift */,
E253C87928B810090076B6D0 /* ImageOutput.swift */,
E253C87B28B8BFB80076B6D0 /* FileSystem.swift */,
E253C87628B767D50076B6D0 /* ImageType.swift */,
E2F8FA2728ACD84400632026 /* VideoType.swift */,
E2D4225028BD242200400E64 /* Configuration.swift */,
);
path = Files;
sourceTree = "<group>";
};
E2C5A5D328A0222B00102A25 /* Templates */ = {
isa = PBXGroup;
children = (
E2C5A5EA28A047B100102A25 /* Filled */,
E2C5A5E728A03E4000102A25 /* Pages */,
E2C5A5E628A03B1600102A25 /* Elements */,
E2C5A5D628A022C500102A25 /* TemplateFactory.swift */,
E22E87AB289F1D3700E51191 /* Template.swift */,
);
path = Templates;
sourceTree = "<group>";
};
E2C5A5E628A03B1600102A25 /* Elements */ = {
isa = PBXGroup;
children = (
E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */,
E2C5A5DC28A036BE00102A25 /* OverviewSectionTemplate.swift */,
E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */,
E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */,
E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */,
E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */,
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */,
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */,
E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */,
);
path = Elements;
sourceTree = "<group>";
};
E2C5A5E728A03E4000102A25 /* Pages */ = {
isa = PBXGroup;
children = (
E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */,
E2C5A5E228A037F900102A25 /* PageTemplate.swift */,
);
path = Pages;
sourceTree = "<group>";
};
E2C5A5EA28A047B100102A25 /* Filled */ = {
isa = PBXGroup;
children = (
E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */,
E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */,
);
path = Filled;
sourceTree = "<group>";
};
E2F8FA2E28AD44FF00632026 /* Content */ = {
isa = PBXGroup;
children = (
E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */,
E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */,
E2F8FA2F28AD450B00632026 /* PageState.swift */,
E2F8FA3328AD6F3400632026 /* Element.swift */,
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */,
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */,
);
path = Content;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
E22E875E289D84C300E51191 /* WebsiteGenerator */ = {
isa = PBXNativeTarget;
buildConfigurationList = E22E8766289D84C300E51191 /* Build configuration list for PBXNativeTarget "WebsiteGenerator" */;
buildPhases = (
E22E875B289D84C300E51191 /* Sources */,
E22E875C289D84C300E51191 /* Frameworks */,
E22E875D289D84C300E51191 /* CopyFiles */,
);
buildRules = (
);
dependencies = (
);
name = WebsiteGenerator;
packageProductDependencies = (
E22E878B289E4A8900E51191 /* Ink */,
E2F8FA2A28AD0BD200632026 /* Splash */,
);
productName = WebsiteGenerator;
productReference = E22E875F289D84C300E51191 /* WebsiteGenerator */;
productType = "com.apple.product-type.tool";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
E22E8757289D84C300E51191 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1340;
LastUpgradeCheck = 1340;
TargetAttributes = {
E22E875E289D84C300E51191 = {
CreatedOnToolsVersion = 13.4.1;
};
};
};
buildConfigurationList = E22E875A289D84C300E51191 /* Build configuration list for PBXProject "WebsiteGenerator" */;
compatibilityVersion = "Xcode 13.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = E22E8756289D84C300E51191;
packageReferences = (
E22E878A289E4A8900E51191 /* XCRemoteSwiftPackageReference "ink" */,
E2F8FA2928AD0BD200632026 /* XCRemoteSwiftPackageReference "Splash" */,
);
productRefGroup = E22E8760289D84C300E51191 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
E22E875E289D84C300E51191 /* WebsiteGenerator */,
);
};
/* End PBXProject section */
/* Begin PBXSourcesBuildPhase section */
E22E875B289D84C300E51191 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */,
E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */,
E2F8FA3A28AE313A00632026 /* ValidationLog.swift in Sources */,
E253C88528BA32FB0076B6D0 /* HTMLElementsGenerator.swift in Sources */,
E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */,
E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */,
E2F8FA2D28AD2F5300632026 /* GenericMetadata.swift in Sources */,
E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */,
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */,
E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */,
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */,
E253C87C28B8BFB80076B6D0 /* FileSystem.swift in Sources */,
E2F8FA3428AD6F3400632026 /* Element.swift in Sources */,
E253C87F28B8FBB00076B6D0 /* Data+Extensions.swift in Sources */,
E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */,
E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */,
E22E877D289DBA0A00E51191 /* OverviewSectionGenerator.swift in Sources */,
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */,
E22E87A4289F0C7000E51191 /* SiteGenerator.swift in Sources */,
E22E87AC289F1D3700E51191 /* Template.swift in Sources */,
E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */,
E2D4225128BD242200400E64 /* Configuration.swift in Sources */,
E2F8FA3028AD450B00632026 /* PageState.swift in Sources */,
E253C87728B767D50076B6D0 /* ImageType.swift in Sources */,
E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */,
E22E87A8289F0E7B00E51191 /* PageGenerator.swift in Sources */,
E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */,
E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */,
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */,
E253C88328B8FC470076B6D0 /* NSImage+Extensions.swift in Sources */,
E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */,
E2C5A5DB28A02F9000102A25 /* TopBarTemplate.swift in Sources */,
E2C5A5E928A0451C00102A25 /* LocalizedSiteTemplate.swift in Sources */,
E2C5A5E128A0373300102A25 /* ThumbnailTemplate.swift in Sources */,
E22E8795289E81D700E51191 /* URL+Extensions.swift in Sources */,
E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */,
E22E8763289D84C300E51191 /* main.swift in Sources */,
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */,
E2F8FA3228AD456C00632026 /* GenericMetadata+Localized.swift in Sources */,
E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */,
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */,
E253C87A28B810090076B6D0 /* ImageOutput.swift in Sources */,
E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */,
E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */,
E253C88128B8FBFF0076B6D0 /* NSSize+Extensions.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
E22E8764289D84C300E51191 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.3;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
E22E8765289D84C300E51191 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 12.3;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
};
name = Release;
};
E22E8767289D84C300E51191 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = H8WR4M6QQ4;
ENABLE_HARDENED_RUNTIME = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
};
name = Debug;
};
E22E8768289D84C300E51191 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = H8WR4M6QQ4;
ENABLE_HARDENED_RUNTIME = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
E22E875A289D84C300E51191 /* Build configuration list for PBXProject "WebsiteGenerator" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E22E8764289D84C300E51191 /* Debug */,
E22E8765289D84C300E51191 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
E22E8766289D84C300E51191 /* Build configuration list for PBXNativeTarget "WebsiteGenerator" */ = {
isa = XCConfigurationList;
buildConfigurations = (
E22E8767289D84C300E51191 /* Debug */,
E22E8768289D84C300E51191 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
E22E878A289E4A8900E51191 /* XCRemoteSwiftPackageReference "ink" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/johnsundell/ink.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.5.1;
};
};
E2F8FA2928AD0BD200632026 /* XCRemoteSwiftPackageReference "Splash" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/JohnSundell/Splash";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.16.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
E22E878B289E4A8900E51191 /* Ink */ = {
isa = XCSwiftPackageProductDependency;
package = E22E878A289E4A8900E51191 /* XCRemoteSwiftPackageReference "ink" */;
productName = Ink;
};
E2F8FA2A28AD0BD200632026 /* Splash */ = {
isa = XCSwiftPackageProductDependency;
package = E2F8FA2928AD0BD200632026 /* XCRemoteSwiftPackageReference "Splash" */;
productName = Splash;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = E22E8757289D84C300E51191 /* Project object */;
}

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Workspace
version = "1.0">
<FileRef
location = "self:">
</FileRef>
</Workspace>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>IDEDidComputeMac32BitWarning</key>
<true/>
</dict>
</plist>

View File

@ -1,23 +0,0 @@
{
"pins" : [
{
"identity" : "ink",
"kind" : "remoteSourceControl",
"location" : "https://github.com/johnsundell/ink.git",
"state" : {
"revision" : "77c3d8953374a9cf5418ef0bd7108524999de85a",
"version" : "0.5.1"
}
},
{
"identity" : "splash",
"kind" : "remoteSourceControl",
"location" : "https://github.com/JohnSundell/Splash",
"state" : {
"revision" : "7f4df436eb78fe64fe2c32c58006e9949fa28ad8",
"version" : "0.16.0"
}
}
],
"version" : 2
}

View File

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1340"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E22E875E289D84C300E51191"
BuildableName = "WebsiteGenerator"
BlueprintName = "WebsiteGenerator"
ReferencedContainer = "container:WebsiteGenerator.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E22E875E289D84C300E51191"
BuildableName = "WebsiteGenerator"
BlueprintName = "WebsiteGenerator"
ReferencedContainer = "container:WebsiteGenerator.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "E22E875E289D84C300E51191"
BuildableName = "WebsiteGenerator"
BlueprintName = "WebsiteGenerator"
ReferencedContainer = "container:WebsiteGenerator.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "A4ABB69A-4D7A-40A2-832F-5B0B63325500"
type = "1"
version = "2.0">
</Bucket>

View File

@ -1,22 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>WebsiteGenerator.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>E22E875E289D84C300E51191</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@ -1,519 +0,0 @@
import Foundation
struct Element {
static let overviewItemCountDefault = 6
/**
The default unique id for the root element
*/
static let defaultRootId = "root"
/**
The unique id of the element.
The id is used for short-hand links to pages, in the form of `![page](page_id)`
for thumbnail previews or `[text](page:page_id)` for simple links.
- Note: The default id for the root element is specified by ``defaultRootId``
The id can be manually specified using ``GenericMetadata.id``,
otherwise it is set to the name of the element folder.
*/
let id: String
/**
The author of the content.
If no author is set, then the author from the parent element is used.
*/
let author: String
/**
The title used in the top bar of the website, next to the logo.
This title can be HTML content, and only the root level value is used.
*/
let topBarTitle: String
/**
The (start) date of the element.
The date is printed on content pages and may also used for sorting elements,
depending on the `useManualSorting` property of the parent.
*/
let date: Date?
/**
The end date of the element.
This property can be used to specify a date range for a content page.
*/
let endDate: Date?
/**
The deployment state of the page.
- Note: This property defaults to ``PageState.standard`
*/
let state: PageState
/**
The sort index of the page for manual sorting.
- Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set.
*/
let sortIndex: Int?
/**
All files which may occur in content but is stored externally.
Missing files which would otherwise produce a warning are ignored when included here.
- Note: This property defaults to an empty set.
*/
let externalFiles: Set<String>
/**
Specifies additional files which should be copied to the destination when generating the content.
- Note: This property defaults to an empty set.
*/
let requiredFiles: Set<String>
/**
The style of thumbnail to use when generating overviews.
- Note: This property is only relevant for sections.
- Note: This property is inherited from the parent if not specified.
*/
let thumbnailStyle: ThumbnailStyle
/**
Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`.
- Note: This property is only relevant for sections.
- Note: This property defaults to `false`
*/
let useManualSorting: Bool
/**
The number of items to show when generating overviews of this element.
- Note: This property is only relevant for sections.
- Note: This property is inherited from the parent if not specified.
*/
let overviewItemCount: Int
/**
Indicate that no header should be generated automatically.
This option assumes that custom header code is present in the page source files
- Note: If not specified, this property defaults to `false`.
*/
let useCustomHeader: Bool
/**
The localized metadata for each language.
*/
let languages: [LocalizedMetadata]
/**
All elements contained within the element.
If the element is a section, then this property contains the pages or subsections within.
*/
var elements: [Element] = []
/**
The url of the element's folder in the source hierarchy.
- Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property.
*/
let inputFolder: URL
/**
The path to the element's folder in the source hierarchy (without a leading slash).
*/
let path: String
/**
Create the root element of a site.
The root element will recursively move into subfolders and build the site content
by looking for metadata files in each subfolder.
- Parameter folder: The root folder of the site content.
- Note: Uses global objects.
*/
init?(atRoot folder: URL) throws {
self.inputFolder = folder
self.path = ""
let source = GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else {
return nil
}
self.id = metadata.customId ?? Element.defaultRootId
self.author = log.required(metadata.author, name: "author", source: source) ?? "author"
self.topBarTitle = log
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
self.date = log.unused(metadata.date, "date", source: source)
self.endDate = log.unused(metadata.endDate, "endDate", source: source)
self.state = log.state(metadata.state, source: source)
self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source)
self.externalFiles = metadata.externalFiles ?? []
self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root
self.thumbnailStyle = log.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
self.useCustomHeader = metadata.useCustomHeader ?? false
self.languages = log.required(metadata.languages, name: "languages", source: source)?
.compactMap { language in
.init(atRoot: folder, data: language)
} ?? []
// All properties initialized
files.add(page: path, id: id)
try self.readElements(in: folder, source: nil)
}
mutating func readElements(in folder: URL, source: String?) throws {
let subFolders: [URL]
do {
subFolders = try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.isDirectory }
} catch {
log.add(error: "Failed to read subfolders", source: source ?? "root", error: error)
return
}
self.elements = try subFolders.compactMap { subFolder in
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent
return try Element(parent: self, folder: subFolder, path: s)
}
}
init?(parent: Element, folder: URL, path: String) throws {
self.inputFolder = folder
self.path = path
let source = path + "/" + GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else {
return nil
}
self.id = metadata.customId ?? folder.lastPathComponent
self.author = metadata.author ?? parent.author
self.topBarTitle = log
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
let date = log.date(from: metadata.date, property: "date", source: source).ifNil {
if !parent.useManualSorting {
log.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source)
}
}
self.date = date
self.endDate = log.date(from: metadata.endDate, property: "endDate", source: source).ifNotNil {
if date == nil {
log.add(warning: "Set 'endDate', but no 'date'", source: source)
}
}
self.state = log.state(metadata.state, source: source)
self.sortIndex = metadata.sortIndex.ifNil {
if parent.useManualSorting {
log.add(error: "No 'sortIndex', but parent defines 'useManualSorting' = true", source: source)
}
}
// 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.thumbnailStyle = log.thumbnailStyle(metadata.thumbnailStyle, source: source)
self.useManualSorting = metadata.useManualSorting ?? false
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
self.useCustomHeader = metadata.useCustomHeader ?? false
self.languages = parent.languages.compactMap { parentData in
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
log.add(info: "Language '\(parentData.language)' not found", source: source)
return nil
}
return .init(folder: folder, data: data, source: source, parent: parentData)
}
// Check that each 'language' tag is present, and that all languages appear in the parent
log.required(metadata.languages, name: "languages", source: source)?
.compactMap { log.required($0.language, name: "language", source: source) }
.filter { language in
!parent.languages.contains { $0.language == language }
}
.forEach {
log.add(warning: "Language '\($0)' not found in parent, so not generated", source: source)
}
// All properties initialized
files.add(page: path, id: id)
try self.readElements(in: folder, source: path)
}
}
// MARK: Paths
extension Element {
/**
The localized html file name for a language, including a leading slash.
*/
static func htmlPagePathAddition(for language: String) -> String {
"/" + htmlPageName(for: language)
}
/**
The localized html file name for a language, without the leading slash.
*/
static func htmlPageName(for language: String) -> String {
"\(language).html"
}
var containsElements: Bool {
!elements.isEmpty
}
var hasNestingElements: Bool {
elements.contains { $0.containsElements }
}
func itemsForOverview(_ count: Int? = nil) -> [Element] {
if let shownItemCount = count {
return Array(sortedItems.prefix(shownItemCount))
} else {
return sortedItems
}
}
var sortedItems: [Element] {
if useManualSorting {
return shownItems.sorted { $0.sortIndex! < $1.sortIndex! }
}
return shownItems.sorted { $0.date! > $1.date! }
}
private var shownItems: [Element] {
elements.filter { $0.state.isShownInOverview }
}
/**
The url of the top-level section of the element.
*/
func sectionUrl(for language: String) -> String {
path.components(separatedBy: "/").first! + Element.htmlPagePathAddition(for: language)
}
/**
Create a relative link to another page in the tree.
- Parameter pageUrl: The full page url of the target page, including localization
- Returns: The relative url from a localized page of the element to the target page.
*/
func relativePathToOtherSiteElement(pageUrl: String) -> String {
// Note: The element `path` is missing the last component
// i.e. travel/alps instead of travel/alps/en.html
let ownParts = path.components(separatedBy: "/")
let pageParts = pageUrl.components(separatedBy: "/")
// Find the common elements of the path, which can be discarded
var index = 0
while pageParts[index] == ownParts[index] {
index += 1
}
// The relative path needs to go down to the first common folder,
// before going up to the target page
let allParts = [String](repeating: "..", count: ownParts.count-index)
+ pageParts.dropFirst(index)
return allParts.joined(separator: "/")
}
/**
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
This function is used to copy required input files and to generate images
*/
func pathRelativeToRootForContainedInputFile(_ filePath: String) -> String {
Element.relativeToRoot(filePath: filePath, folder: path)
}
/**
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
This function is used to copy required input files and to generate images
*/
func nonAbsolutePathRelativeToRootForContainedInputFile(_ filePath: String) -> String? {
Element.containedFileRelativeToRoot(filePath: filePath, folder: path)
}
static func relativeToRoot(filePath: String, folder path: String) -> String {
containedFileRelativeToRoot(filePath: filePath, folder: path) ?? filePath
}
static func containedFileRelativeToRoot(filePath: String, folder path: String) -> String? {
if filePath.hasPrefix("/") || filePath.hasPrefix("http") || filePath.hasPrefix("mailto:") {
return nil
}
return "\(path)/\(filePath)"
}
static func rootPaths(for input: Set<String>?, path: String) -> Set<String> {
input.unwrapped { Set($0.map { relativeToRoot(filePath: $0, folder: path) }) } ?? []
}
func relativePathToFileWithPath(_ filePath: String) -> String {
guard path != "" else {
return filePath
}
guard filePath.hasPrefix(path) else {
return filePath
}
return filePath.replacingOccurrences(of: path + "/", with: "")
}
}
// MARK: Accessing localizations
extension Element {
/**
Get the full path of the thumbnail image for the language (relative to the root folder).
*/
func thumbnailFilePath(for language: String) -> String {
guard let thumbnailFile = Element.findThumbnail(for: language, in: inputFolder) else {
log.add(error: "Missing thumbnail", source: path)
return Element.defaultThumbnailName
}
return pathRelativeToRootForContainedInputFile(thumbnailFile)
}
func fullPageUrl(for language: String) -> String {
localized(for: language).externalUrl ?? localizedPath(for: language)
}
func localized(for language: String) -> LocalizedMetadata {
languages.first { $0.language == language }!
}
func title(for language: String) -> String {
localized(for: language).title
}
/**
Get the back link text for the element.
This text is the one printed for pages of the element, which uses the back text specified by the parent.
*/
func backLinkText(for language: String) -> String {
localized(for: language).parentBackLinkText
}
/**
The optional text to display in a thumbnail corner.
- Note: This text is only displayed for large thumbnails.
*/
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
/**
Returns the full path (relative to the site root for a page of the element in the given language.
*/
func localizedPath(for language: String) -> String {
guard path != "" else {
return Element.htmlPageName(for: language)
}
return path + Element.htmlPagePathAddition(for: language)
}
/**
Get the next language to switch to with the language button.
*/
func nextLanguage(for language: String) -> String? {
let langs = languages.map { $0.language }
guard let index = langs.firstIndex(of: language) else {
return nil
}
for i in 1..<langs.count {
let next = langs[(index + i) % langs.count]
guard hasContent(for: next) else {
continue
}
guard next != language else {
return nil
}
return next
}
return nil
}
func linkPreviewImage(for language: String) -> String? {
localized(for: language).linkPreviewImage
}
}
// MARK: Page content
extension Element {
var isExternalPage: Bool {
languages.contains { $0.externalUrl != nil }
}
/**
Get the url of the content markdown file for a language.
To check if the file also exists, use `existingContentUrl(for:)`
*/
private func contentUrl(for language: String) -> URL {
inputFolder.appendingPathComponent("\(language).md")
}
/**
Get the url of existing markdown content for a language.
*/
private func existingContentUrl(for language: String) -> URL? {
let url = contentUrl(for: language)
guard url.exists else {
return nil
}
return url
}
private func hasContent(for language: String) -> Bool {
if !elements.isEmpty {
return true
}
return existingContentUrl(for: language) != nil
}
}
// MARK: Header and Footer
extension Element {
private var additionalHeadContentPath: String {
path + "/head.html"
}
func customHeadContent() -> String? {
files.contentOfOptionalFile(atPath: additionalHeadContentPath, source: path)
}
private var additionalFooterContentPath: String {
path + "/footer.html"
}
func customFooterContent() -> String? {
files.contentOfOptionalFile(atPath: additionalFooterContentPath, source: path)
}
}
// MARK: Debug
extension Element {
func printTree(indentation: String = "") {
print(indentation + "/" + path)
elements.forEach { $0.printTree(indentation: indentation + " ") }
}
}

View File

@ -1,38 +0,0 @@
import Foundation
extension URL {
func ensureParentFolderExistence() throws {
try deletingLastPathComponent().ensureFolderExistence()
}
func ensureFolderExistence() throws {
guard !exists else {
return
}
try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true)
}
var isDirectory: Bool {
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
}
var exists: Bool {
FileManager.default.fileExists(atPath: path)
}
/**
Delete the file at the url.
*/
func delete() throws {
try FileManager.default.removeItem(at: self)
}
func copy(to url: URL) throws {
if url.exists {
try url.delete()
}
try url.ensureParentFolderExistence()
try FileManager.default.copyItem(at: self, to: url)
}
}

View File

@ -1,6 +0,0 @@
import Foundation
struct Configuration {
let pageImageWidth: Int
}

View File

@ -1,481 +0,0 @@
import Foundation
import CryptoKit
import AppKit
typealias SourceFile = (data: Data, didChange: Bool)
typealias SourceTextFile = (content: String, didChange: Bool)
final class FileSystem {
private static let hashesFileName = "hashes.json"
private let input: URL
private let output: URL
private let source = "FileChangeMonitor"
private var hashesFile: URL {
input.appendingPathComponent(FileSystem.hashesFileName)
}
/**
The hashes of all accessed files from the previous run
The key is the relative path to the file from the source
*/
private var previousFiles: [String : Data] = [:]
/**
The paths of all files which were accessed, with their new hashes
This list is used to check if a file was modified, and to write all accessed files back to disk
*/
private var accessedFiles: [String : Data] = [:]
/**
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 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] = [:]
init(in input: URL, to output: URL) {
self.input = input
self.output = output
guard exists(hashesFile) else {
log.add(info: "No file hashes loaded, regarding all content as new", source: source)
return
}
let data: Data
do {
data = try Data(contentsOf: hashesFile)
} catch {
log.add(
warning: "File hashes could not be read, regarding all content as new",
source: source,
error: error)
return
}
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
}
}
func urlInOutputFolder(_ path: String) -> URL {
output.appendingPathComponent(path)
}
func urlInContentFolder(_ path: String) -> URL {
input.appendingPathComponent(path)
}
/**
Get the current hash of file data at a path.
If the hash has been computed previously during the current run, then this function directly returns it.
*/
private func hash(_ data: Data, at path: String) -> Data {
accessedFiles[path] ?? SHA256.hash(data: data).data
}
private func exists(_ url: URL) -> Bool {
FileManager.default.fileExists(atPath: url.path)
}
func dataOfRequiredFile(atPath path: String, source: String) -> Data? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
log.failedToOpen(path, requiredBy: source, error: nil)
return nil
}
do {
return try Data(contentsOf: url)
} catch {
log.failedToOpen(path, requiredBy: source, error: error)
return nil
}
}
func dataOfOptionalFile(atPath path: String, source: String) -> Data? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
return nil
}
do {
return try Data(contentsOf: url)
} catch {
log.failedToOpen(path, requiredBy: source, error: error)
return nil
}
}
func contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
if createEmptyFileIfMissing {
try? Data().write(to: url)
}
return nil
}
do {
return try String(contentsOf: url)
} catch {
log.failedToOpen(path, requiredBy: source, error: error)
return nil
}
}
private func getData(atPath path: String) -> SourceFile? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
return nil
}
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
log.add(error: "Failed to read data at \(path)", source: source, error: error)
return nil
}
let newHash = hash(data, at: path)
defer {
accessedFiles[path] = newHash
}
guard let oldHash = previousFiles[path] else {
return (data: data, didChange: true)
}
return (data: data, didChange: oldHash != newHash)
}
func writeHashes() {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let data = try encoder.encode(accessedFiles)
try data.write(to: hashesFile)
} catch {
log.add(warning: "Failed to save file hashes", source: source, error: error)
}
}
// MARK: Images
private func loadImage(atPath path: String) -> (image: NSImage, changed: Bool)? {
guard let (data, changed) = 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, changed)
}
@discardableResult
func requireImage(source: String, destination: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
let height = desiredHeight.unwrapped(CGFloat.init)
let sourceUrl = input.appendingPathComponent(source)
let image = ImageOutput(source: source, width: width, desiredHeight: desiredHeight)
let standardSize = NSSize(width: CGFloat(width), height: height ?? CGFloat(width) / 16 * 9)
guard sourceUrl.exists else {
log.add(error: "Missing file with size (\(width),\(desiredHeight ?? -1))",
source: source)
return standardSize
}
guard let imageSize = loadImage(atPath: image.source)?.image.size else {
log.add(error: "Unreadable image with size (\(width),\(desiredHeight ?? -1))",
source: source)
return standardSize
}
let scaledSize = imageSize.scaledDown(to: CGFloat(width))
guard let existing = imageTasks[destination] else {
imageTasks[destination] = image
return scaledSize
}
guard existing.source == source else {
log.add(error: "Multiple sources (\(existing.source),\(source))",
source: destination)
return scaledSize
}
guard existing.hasSimilarRatio(as: image) else {
log.add(error: "Multiple ratios (\(existing.ratio!),\(image.ratio!))",
source: destination)
return scaledSize
}
if image.width > existing.width {
log.add(info: "Increasing size from \(existing.width) to \(width)",
source: destination)
imageTasks[destination] = image
}
return scaledSize
}
func createImages() {
for (destination, image) in imageTasks.sorted(by: { $0.key < $1.key }) {
createImageIfNeeded(image, for: destination)
}
}
private func createImageIfNeeded(_ image: ImageOutput, for destination: String) {
guard let (sourceImageData, sourceImageChanged) = getData(atPath: image.source) else {
log.add(error: "Failed to open file", source: image.source)
return
}
let destinationUrl = output.appendingPathComponent(destination)
// Check if image needs to be updated
guard !destinationUrl.exists || sourceImageChanged else {
return
}
// Ensure that image file is supported
let ext = destinationUrl.pathExtension.lowercased()
guard ImageType(fileExtension: ext) != nil else {
// TODO: This should never be reached, since extensions are checked before
log.add(info: "Copying image", source: image.source)
do {
let sourceUrl = input.appendingPathComponent(image.source)
try destinationUrl.ensureParentFolderExistence()
try sourceUrl.copy(to: destinationUrl)
} catch {
log.add(error: "Failed to copy image", source: destination)
}
return
}
guard let sourceImage = NSImage(data: sourceImageData) else {
log.add(error: "Failed to read file", source: image.source)
return
}
let desiredWidth = CGFloat(image.width)
let desiredHeight = image.desiredHeight.unwrapped(CGFloat.init)
let destinationSize = sourceImage.size.scaledDown(to: desiredWidth)
let scaledImage = sourceImage.scaledDown(to: destinationSize)
let scaledSize = scaledImage.size
if abs(scaledImage.size.width - desiredWidth) > 2 {
log.add(warning: "Desired width \(desiredWidth), got \(scaledSize.width)", source: destination)
}
if abs(destinationSize.height - scaledImage.size.height) > 2 {
log.add(warning: "Desired height \(destinationSize.height), got \(scaledSize.height)", source: destination)
}
if let desiredHeight = desiredHeight {
let desiredRatio = desiredHeight / desiredWidth
let adjustedDesiredHeight = scaledSize.width * desiredRatio
if abs(adjustedDesiredHeight - scaledSize.height) > 5 {
log.add(warning: "Desired height \(desiredHeight), got \(scaledSize.height)", source: destination)
return
}
}
if scaledSize.width > desiredWidth {
log.add(warning:" Desired width \(desiredWidth), got \(scaledSize.width)", source: destination)
}
let destinationExtension = destinationUrl.pathExtension.lowercased()
guard let type = ImageType(fileExtension: destinationExtension)?.fileType else {
log.add(error: "No image type for extension \(destinationExtension)",
source: destination)
return
}
guard let tiff = scaledImage.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else {
log.add(error: "Failed to get data", source: image.source)
return
}
guard let data = tiffData.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
log.add(error: "Failed to get data", source: image.source)
return
}
do {
try data.createFolderAndWrite(to: destinationUrl)
} catch {
log.add(error: "Failed to write image \(destination)", source: "Image Processor", error: error)
return
}
}
// MARK: File copying
/**
Add a file as required, so that it will be copied to the output directory.
*/
func require(file: String) {
requiredFiles.insert(file)
}
/**
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() {
for file in requiredFiles {
let cleanPath = cleanRelativeURL(file)
let sourceUrl = input.appendingPathComponent(cleanPath)
let destinationUrl = output.appendingPathComponent(cleanPath)
guard sourceUrl.exists else {
if !externalFiles.contains(file) {
log.add(error: "Missing required file", source: cleanPath)
}
continue
}
let data: Data
do {
data = try Data(contentsOf: sourceUrl)
} catch {
log.add(error: "Failed to read data at \(sourceUrl.path)", source: source, error: error)
continue
}
writeIfChanged(data, to: destinationUrl)
}
for (file, source) in expectedFiles {
guard !externalFiles.contains(file) else {
continue
}
let cleanPath = cleanRelativeURL(file)
let destinationUrl = output.appendingPathComponent(cleanPath)
if !destinationUrl.exists {
log.add(error: "Missing \(cleanPath)", source: source)
}
}
}
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: "/")
}
// MARK: Pages
func isEmpty(page: String) {
emptyPages.insert(page)
}
func printEmptyPages() {
guard !emptyPages.isEmpty else {
return
}
log.add(info: "\(emptyPages.count) empty pages:", source: "Files")
for page in emptyPages.sorted() {
log.add(info: "\(page) has no content", source: "Files")
}
}
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]
}
// 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)
}
}
private extension Digest {
var bytes: [UInt8] { Array(makeIterator()) }
var data: Data { Data(bytes) }
var hexStr: String {
bytes.map { String(format: "%02X", $0) }.joined()
}
}

View File

@ -1,153 +0,0 @@
import Foundation
final class ValidationLog {
private enum LogLevel: String {
case error = "ERROR"
case warning = "WARNING"
case info = "INFO"
}
init() {
}
private func add(_ type: LogLevel, item: ContentError) {
let errorText: String
if let err = item.error {
errorText = ", Error: \(err.localizedDescription)"
} else {
errorText = ""
}
print("[\(type.rawValue)][\(item.source)] \(item.reason)\(errorText)")
}
func add(error: ContentError) {
add(.error, item: error)
}
func add(error reason: String, source: String, error: Error? = nil) {
add(error: .init(reason: reason, source: source, error: error))
}
func add(warning: ContentError) {
add(.warning, item: warning)
}
func add(warning reason: String, source: String, error: Error? = nil) {
add(warning: .init(reason: reason, source: source, error: error))
}
func add(info: ContentError) {
add(.info, item: info)
}
func add(info reason: String, source: String, error: Error? = nil) {
add(info: .init(reason: reason, source: source, error: error))
}
@discardableResult
func unused<T, R>(_ value: Optional<T>, _ name: String, source: String) -> Optional<R> {
if value != nil {
add(info: "Unused property '\(name)'", source: source)
}
return nil
}
func unknown(property: String, source: String) {
add(info: "Unknown property '\(property)'", source: source)
}
func required<T>(_ value: Optional<T>, name: String, source: String) -> Optional<T> {
guard let value = value else {
add(error: "Missing property '\(name)'", source: source)
return nil
}
return value
}
func unexpected<T>(_ value: Optional<T>, name: String, source: String) -> Optional<T> {
if let value = value {
add(error: "Unexpected property '\(name)' = '\(value)'", source: source)
return nil
}
return nil
}
func missing(_ file: String, requiredBy source: String) {
print("[ERROR] Missing file '\(file)' required by \(source)")
}
func failedToOpen(_ file: String, requiredBy source: String, error: Error?) {
print("[ERROR] Failed to open file '\(file)' required by \(source): \(error?.localizedDescription ?? "No error provided")")
}
func state(_ raw: String?, source: String) -> PageState {
guard let raw = raw else {
return .standard
}
guard let state = PageState(rawValue: raw) else {
add(warning: "Invalid 'state' '\(raw)', using 'standard'", source: source)
return .standard
}
return state
}
func thumbnailStyle(_ raw: String?, source: String) -> ThumbnailStyle {
guard let raw = raw else {
return .large
}
guard let style = ThumbnailStyle(rawValue: raw) else {
add(warning: "Invalid 'thumbnailStyle' '\(raw)', using 'large'", source: source)
return .large
}
return style
}
func linkPreviewThumbnail(customFile: String?, for language: String, in folder: URL, source: String) -> String? {
if let customFile = customFile {
#warning("Allow absolute urls for link preview thumbnails")
let customFileUrl = folder.appendingPathComponent(customFile)
guard customFileUrl.exists else {
missing(customFile, requiredBy: "property 'linkPreviewImage' in metadata of \(source)")
return nil
}
return customFile
}
guard let thumbnail = Element.findThumbnail(for: language, in: folder) else {
// Link preview images are not necessarily required
return nil
}
return thumbnail
}
func moreLinkText(_ elementText: String?, parent parentText: String?, source: String) -> String {
if let elementText = elementText {
return elementText
}
let standard = Element.LocalizedMetadata.moreLinkDefaultText
guard let parentText = parentText, parentText != standard else {
add(error: "Missing property 'moreLinkText'", source: source)
return standard
}
return parentText
}
func date(from string: String?, property: String, source: String) -> Date? {
guard let string = string else {
return nil
}
guard let date = ValidationLog.metadataDate.date(from: string) else {
add(warning: "Invalid date string '\(string)' for property '\(property)'", source: source)
return nil
}
return date
}
private static let metadataDate: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yy"
return df
}()
}

View File

@ -1,39 +0,0 @@
import Foundation
struct IndexPageGenerator {
private let factory: LocalizedSiteTemplate
init(factory: LocalizedSiteTemplate) {
self.factory = factory
}
func generate(site: Element, language: String) {
let localized = site.localized(for: language)
let path = site.localizedPath(for: language)
let pageUrl = files.urlInOutputFolder(path)
let languageButton = site.nextLanguage(for: language)
let sectionItemCount = site.overviewItemCount
var content = [PageTemplate.Key : String]()
content[.head] = factory.pageHead.generate(page: site, language: language)
content[.topBar] = factory.topBar.generate(sectionUrl: nil, languageButton: languageButton)
content[.contentClass] = "overview"
content[.header] = makeHeader(page: site, metadata: localized, language: language)
content[.content] = factory.overviewSection.generate(
sections: site.sortedItems,
in: site,
language: language,
sectionItemCount: sectionItemCount)
content[.footer] = site.customFooterContent()
guard factory.page.generate(content, to: pageUrl) else {
return
}
log.add(info: "Page generated", source: path)
}
private func makeHeader(page: Element, metadata: Element.LocalizedMetadata, language: String) -> String {
let content = factory.makeHeaderContent(page: page, metadata: metadata, language: language)
return factory.factory.centeredHeader.generate(content)
}
}

View File

@ -1,208 +0,0 @@
import Foundation
import Ink
import Splash
struct PageContentGenerator {
private let factory: TemplateFactory
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
init(factory: TemplateFactory) {
self.factory = factory
}
func generate(page: Element, language: String, content: String) -> (content: String, includesCode: Bool) {
var hasCodeContent = false
let imageModifier = Modifier(target: .images) { html, markdown in
processMarkdownImage(markdown: markdown, html: html, page: page)
}
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
if markdown.starts(with: "```swift") {
let code = markdown.between("```swift", and: "```").trimmed
return "<pre><code>" + swift.highlight(code) + "</pre></code>"
}
hasCodeContent = true
return html
}
let linkModifier = Modifier(target: .links) { html, markdown in
handleLink(page: page, language: language, html: html, markdown: markdown)
}
let htmlModifier = Modifier(target: .html) { html, markdown in
handleHTML(page: page, language: language, html: html, markdown: markdown)
}
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier, htmlModifier])
return (parser.html(from: content), hasCodeContent)
}
private func handleLink(page: Element, language: String, html: String, markdown: Substring) -> String {
let file = markdown.between("(", and: ")")
if file.hasPrefix("page:") {
let pageId = file.replacingOccurrences(of: "page:", with: "")
guard let pagePath = files.getPage(for: pageId) else {
log.add(warning: "Page id '\(pageId)' not found", source: page.path)
// Remove link since the page can't be found
return markdown.between("[", and: "]")
}
let fullPath = pagePath + Element.htmlPagePathAddition(for: language)
// Adjust file path to get the page url
let url = page.relativePathToOtherSiteElement(pageUrl: fullPath)
return html.replacingOccurrences(of: file, with: url)
}
if let filePath = page.nonAbsolutePathRelativeToRootForContainedInputFile(file) {
// The target of the page link must be present after generation is complete
files.expect(file: filePath, source: page.path)
}
return html
}
private func handleHTML(page: Element, language: String, html: String, markdown: Substring) -> String {
#warning("Check HTML code in markdown for required resources")
//print("[HTML] Found in page \(page.path):")
//print(markdown)
// Things to check:
// <img src=
// <a href=
//
return html
}
private func processMarkdownImage(markdown: Substring, html: String, page: Element) -> String {
// Split the markdown ![alt](file title)
// For images: ![left_title](file right_title)
// For videos: ![option1,option2,...](file)
// For svg with custom area: ![x,y,width,height](file.svg)
// For downloads: ![download](file1,text1;file2,text2, ...)
// For files: ?
let fileAndTitle = markdown.between("(", and: ")")
let alt = markdown.between("[", and: "]").nonEmpty
if alt == "download" {
return handleDownloadButtons(page: page, content: fileAndTitle)
}
if alt == "external" {
return handleExternalButtons(page: page, content: fileAndTitle)
}
let file = fileAndTitle.dropAfterFirst(" ")
let title = fileAndTitle.contains(" ") ? fileAndTitle.dropBeforeFirst(" ").nonEmpty : nil
let fileExtension = file.lastComponentAfter(".").lowercased()
if let _ = ImageType(fileExtension: fileExtension) {
return handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
}
if let _ = VideoType(rawValue: fileExtension) {
return handleVideo(page: page, file: file, optionString: alt)
}
if fileExtension == "svg" {
return handleSvg(page: page, file: file, area: alt)
}
return handleFile(page: page, file: file, fileExtension: fileExtension)
}
private func handleImage(page: Element, file: String, rightTitle: String?, leftTitle: String?) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
let size = files.requireImage(source: imagePath, destination: imagePath, width: configuration.pageImageWidth)
let imagePath2x = imagePath.insert("@2x", beforeLast: ".")
let file2x = file.insert("@2x", beforeLast: ".")
files.requireImage(source: imagePath, destination: imagePath2x, width: 2 * configuration.pageImageWidth)
let content: [PageImageTemplate.Key : String] = [
.image: file,
.image2x: file2x,
.width: "\(Int(size.width))",
.height: "\(Int(size.height))",
.leftText: leftTitle ?? "",
.rightText: rightTitle ?? ""]
return factory.image.generate(content)
}
private func handleVideo(page: Element, file: String, optionString: String?) -> String {
let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in
string.components(separatedBy: " ").compactMap { optionText in
guard let optionText = optionText.trimmed.nonEmpty else {
return nil
}
guard let option = PageVideoTemplate.VideoOption(rawValue: optionText) else {
log.add(warning: "Unknown video option \(optionText)", source: page.path)
return nil
}
return option
}
} ?? []
#warning("Check page folder for alternative video versions")
let sources: [PageVideoTemplate.VideoSource] = [(url: file, type: .mp4)]
let filePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: filePath)
return factory.video.generate(sources: sources, options: options)
}
private func handleSvg(page: Element, file: String, area: String?) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: imagePath)
guard let area = area else {
return factory.html.svgImage(file: file)
}
let parts = area.components(separatedBy: ",").map { $0.trimmed }
guard parts.count == 4,
let x = Int(parts[0]),
let y = Int(parts[1]),
let width = Int(parts[2]),
let height = Int(parts[3]) else {
log.add(warning: "Invalid area string for svg image", source: page.path)
return factory.html.svgImage(file: file)
}
return factory.html.svgImage(file: file, x: x, y: y, width: width, height: height)
}
private func handleFile(page: Element, file: String, fileExtension: String) -> String {
log.add(warning: "Unhandled file \(file) with extension \(fileExtension)", source: page.path)
return ""
}
private func handleDownloadButtons(page: Element, content: String) -> String {
let buttons = content
.components(separatedBy: ";")
.compactMap { button -> (file: String, text: String, downloadName: String?)? in
let parts = button.components(separatedBy: ",")
guard parts.count == 2 || parts.count == 3 else {
log.add(warning: "Invalid button definition", source: page.path)
return nil
}
let file = parts[0].trimmed
let title = parts[1].trimmed
let downloadName = parts.count == 3 ? parts[2].trimmed : nil
// Ensure that file is available
let filePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: filePath)
return (file, title, downloadName)
}
return factory.html.downloadButtons(buttons)
}
private func handleExternalButtons(page: Element, content: String) -> String {
let buttons = content
.components(separatedBy: ";")
.compactMap { button -> (url: String, text: String)? in
let parts = button.components(separatedBy: ",")
guard parts.count == 2 else {
log.add(warning: "Invalid external link definition", source: page.path)
return nil
}
let url = parts[0].trimmed
let title = parts[1].trimmed
return (url, title)
}
return factory.html.externalButtons(buttons)
}
}

View File

@ -1,47 +0,0 @@
import Foundation
struct OverviewSectionGenerator {
private let multipleSectionsTemplate: OverviewSectionTemplate
private let singleSectionsTemplate: OverviewSectionCleanTemplate
private let generator: ThumbnailListGenerator
init(factory: TemplateFactory) {
self.multipleSectionsTemplate = factory.overviewSection
self.singleSectionsTemplate = factory.overviewSectionClean
self.generator = ThumbnailListGenerator(factory: factory)
}
func generate(sections: [Element], in parent: Element, language: String, sectionItemCount: Int) -> String {
sections.map { section in
let metadata = section.localized(for: language)
let fullUrl = section.fullPageUrl(for: language)
let relativeUrl = parent.relativePathToFileWithPath(fullUrl)
var content = [OverviewSectionTemplate.Key : String]()
content[.url] = relativeUrl
content[.title] = metadata.title
content[.items] = generator.generateContent(
items: section.itemsForOverview(sectionItemCount),
parent: parent,
language: language,
style: section.thumbnailStyle)
content[.more] = metadata.moreLinkText
return multipleSectionsTemplate.generate(content)
}
.joined(separator: "\n")
}
func generate(section: Element, language: String) -> String {
var content = [OverviewSectionCleanTemplate.Key : String]()
content[.items] = generator.generateContent(
items: section.itemsForOverview(),
parent: section,
language: language,
style: section.thumbnailStyle)
return singleSectionsTemplate.generate(content)
}
}

View File

@ -1,75 +0,0 @@
import Foundation
import Ink
struct PageGenerator {
struct NavigationLink {
let link: String
let text: String
}
private let factory: LocalizedSiteTemplate
init(factory: LocalizedSiteTemplate) {
self.factory = factory
}
func generate(page: Element, language: String, nextPage: NavigationLink?, previousPage: NavigationLink?) {
guard !page.isExternalPage else {
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 = makeContent(page: page, language: language, path: inputContentPath)
let pageIncludesCode = pageContent?.includesCode ?? false
var content = [PageTemplate.Key : String]()
content[.head] = factory.pageHead.generate(page: page, language: language, includesCode: pageIncludesCode)
let sectionUrl = page.sectionUrl(for: language)
content[.topBar] = factory.topBar.generate(sectionUrl: sectionUrl, languageButton: nextLanguage)
content[.contentClass] = "content"
if !page.useCustomHeader {
content[.header] = makeHeader(page: page, metadata: metadata, language: language)
}
content[.content] = pageContent?.content ?? factory.makePlaceholder(metadata: metadata)
content[.previousPageLinkText] = previousPage.unwrapped { factory.factory.html.makePrevText($0.text) }
content[.previousPageUrl] = previousPage?.link
content[.nextPageLinkText] = nextPage.unwrapped { factory.factory.html.makeNextText($0.text) }
content[.nextPageUrl] = nextPage?.link
content[.footer] = page.customFooterContent()
if pageIncludesCode {
let highlightCode = factory.factory.html.codeHighlightFooter()
content[.footer] = (content[.footer].unwrapped { $0 + "\n" } ?? "") + highlightCode
}
let url = files.urlInOutputFolder(path)
if pageContent == nil {
files.isEmpty(page: path)
}
guard factory.page.generate(content, to: url) else {
return
}
log.add(info: "Page generated", source: path)
}
private func makeContent(page: Element, language: String, path: String) -> (content: String, includesCode: Bool)? {
guard let content = files.contentOfOptionalFile(atPath: path, source: page.path, createEmptyFileIfMissing: true),
content.trimmed != "" else {
return nil
}
return PageContentGenerator(factory: factory.factory)
.generate(page: page, language: language, content: content)
}
private func makeHeader(page: Element, metadata: Element.LocalizedMetadata, language: String) -> String {
let content = factory.makeHeaderContent(page: page, metadata: metadata, language: language)
return factory.factory.leftHeader.generate(content)
}
}

View File

@ -1,55 +0,0 @@
import Foundation
struct SiteGenerator {
let templates: TemplateFactory
init() throws {
let templatesFolder = files.urlInContentFolder("templates")
self.templates = try TemplateFactory(templateFolder: templatesFolder)
}
func generate(site: Element) throws {
site.requiredFiles.forEach(files.require)
site.externalFiles.forEach(files.exclude)
try site.languages.forEach {
try generate(site: site, metadata: $0)
}
}
private func generate(site: Element, metadata: Element.LocalizedMetadata) throws {
let language = metadata.language
let template = try LocalizedSiteTemplate(
factory: templates,
language: language,
site: site)
// Generate sections
let overviewGenerator = OverviewPageGenerator(factory: template)
let pageGenerator = PageGenerator(factory: template)
var elementsToProcess: [Element] = site.elements
while let element = elementsToProcess.popLast() {
// Move recursively down to all pages
elementsToProcess.append(contentsOf: element.elements)
element.requiredFiles.forEach(files.require)
element.externalFiles.forEach(files.exclude)
if !element.elements.isEmpty {
overviewGenerator.generate(section: element, language: language)
} else {
#warning("Determine previous and next pages (with relative links)")
pageGenerator.generate(
page: element,
language: language,
nextPage: nil,
previousPage: nil)
}
}
let generator = IndexPageGenerator(factory: template)
// Generate front page
generator.generate(site: site, language: language)
}
}

View File

@ -1,18 +0,0 @@
import Foundation
struct PageImageTemplate: Template {
enum Key: String, CaseIterable {
case image = "IMAGE"
case image2x = "IMAGE_2X"
case width = "WIDTH"
case height = "HEIGHT"
case leftText = "LEFT_TEXT"
case rightText = "RIGHT_TEXT"
}
static let templateName = "image.html"
let raw: String
}

View File

@ -1,78 +0,0 @@
import Foundation
final class TemplateFactory {
let templateFolder: URL
// MARK: Site Elements
let backNavigation: BackNavigationTemplate
let pageHead: PageHeadTemplate
let topBar: TopBarTemplate
let overviewSection: OverviewSectionTemplate
let overviewSectionClean: OverviewSectionCleanTemplate
let placeholder: PlaceholderTemplate
// MARK: Thumbnails
let largeThumbnail: LargeThumbnailTemplate
let squareThumbnail: SquareThumbnailTemplate
let smallThumbnail: SmallThumbnailTemplate
func thumbnail(style: ThumbnailStyle) -> ThumbnailTemplate {
switch style {
case .large:
return largeThumbnail
case .square:
return squareThumbnail
case .small:
return smallThumbnail
}
}
// MARK: Headers
let leftHeader: LeftHeaderTemplate
let centeredHeader: CenteredHeaderTemplate
// MARK: Pages
let page: PageTemplate
let image: PageImageTemplate
let video: PageVideoTemplate
// MARK: HTML
let html: HTMLElementsGenerator
// MARK: Init
init(templateFolder: URL) 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.placeholder = 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)
self.html = .init()
}
}

View File

@ -1,31 +0,0 @@
import Foundation
private let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace")
private let outputDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace/Site")
let configuration = Configuration(pageImageWidth: 748)
let log = ValidationLog()
let files = FileSystem(in: contentDirectory, to: outputDirectory)
private let siteData: Element
do {
guard let element = try Element(atRoot: contentDirectory) else {
exit(0)
}
siteData = element
} catch {
print(error)
exit(0)
}
private let siteGenerator = try SiteGenerator()
try siteGenerator.generate(site: siteData)
print("Pages generated")
files.createImages()
files.printEmptyPages()
print("Images generated")
files.copyRequiredFiles()
print("Required files copied")
files.writeHashes()

9
config_example.json Normal file
View File

@ -0,0 +1,9 @@
{
"pageImageWidth" : 748,
"fullScreenImageWidth" : 4000,
"minifyJavaScript" : false,
"minifyCSS" : false,
"createMdFilesIfMissing" : false,
"contentPath" : "/path/to/content/folder",
"outputPath" : "/path/to/output/folder")
}

19
install.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Install the required dependencies for CHGenerator
# Install magick to resize images
brew install imagemagick
# 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 (required if `minifyCSS = True`)
# https://github.com/clean-css/clean-css-cli
npm install clean-css-cli -g
# Required to optimize jpg/png/svg
npm install imageoptim-cli -g