Update generation

- Move to global objects for files and validation
- Only write changed files
- Check images for changes before scaling
- Simplify code
This commit is contained in:
Christoph Hagen 2022-08-26 17:40:51 +02:00
parent 91d5bcb66d
commit 80d3c08a93
54 changed files with 1344 additions and 2419 deletions

View File

@ -8,22 +8,11 @@
/* Begin PBXBuildFile section */
E22E8763289D84C300E51191 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8762289D84C300E51191 /* main.swift */; };
E22E876A289D84FD00E51191 /* Section+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8769289D84FD00E51191 /* Section+Metadata.swift */; };
E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E876B289D855D00E51191 /* ThumbnailStyle.swift */; };
E22E876E289D868100E51191 /* Site+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */; };
E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */; };
E22E8778289DA0E100E51191 /* GenerationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8777289DA0E100E51191 /* GenerationError.swift */; };
E22E877A289DA9F900E51191 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8779289DA9F900E51191 /* Site.swift */; };
E22E877D289DBA0A00E51191 /* OverviewSectionGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */; };
E22E877F289DC11F00E51191 /* Site+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E877E289DC11F00E51191 /* Site+Metadata.swift */; };
E22E8782289DCCB600E51191 /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8781289DCCB600E51191 /* Section.swift */; };
E22E8784289DCD5E00E51191 /* Section+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */; };
E22E8787289DDF4C00E51191 /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8786289DDF4C00E51191 /* Page.swift */; };
E22E8789289DDF5700E51191 /* Page+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8788289DDF5700E51191 /* Page+Metadata.swift */; };
E22E878C289E4A8900E51191 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = E22E878B289E4A8900E51191 /* Ink */; };
E22E8793289E7EC700E51191 /* Page+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */; };
E22E8795289E81D700E51191 /* FileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8794289E81D700E51191 /* FileSystem.swift */; };
E22E8798289EA42C00E51191 /* FileProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8797289EA42C00E51191 /* FileProcessor.swift */; };
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 */; };
@ -33,11 +22,12 @@
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 */; };
E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B1289F296700E51191 /* ThumbnailInfo.swift */; };
E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B5289FF67B00E51191 /* Metadata.swift */; };
E253C86928AFD86E0076B6D0 /* FileAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C86828AFD86E0076B6D0 /* FileAccess.swift */; };
E253C86B28AFE0980076B6D0 /* Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C86A28AFE0980076B6D0 /* Context.swift */; };
E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */; };
E253C87728B767D50076B6D0 /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87628B767D50076B6D0 /* MediaType.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 */; };
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 */; };
@ -47,10 +37,7 @@
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 */; };
E2C5A5EC28A055E900102A25 /* SiteElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5EB28A055E900102A25 /* SiteElement.swift */; };
E2D55ED928A1BAD800B9453E /* LanguageContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */; };
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */; };
E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.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 */; };
@ -63,7 +50,7 @@
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 /* ErrorOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3928AE313A00632026 /* ErrorOutput.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 */
@ -82,21 +69,10 @@
/* 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>"; };
E22E8769289D84FD00E51191 /* Section+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+Metadata.swift"; sourceTree = "<group>"; };
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailStyle.swift; sourceTree = "<group>"; };
E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+LocalizedMetadata.swift"; sourceTree = "<group>"; };
E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexPageGenerator.swift; sourceTree = "<group>"; };
E22E8777289DA0E100E51191 /* GenerationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationError.swift; sourceTree = "<group>"; };
E22E8779289DA9F900E51191 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; };
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionGenerator.swift; sourceTree = "<group>"; };
E22E877E289DC11F00E51191 /* Site+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+Metadata.swift"; sourceTree = "<group>"; };
E22E8781289DCCB600E51191 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = "<group>"; };
E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+LocalizedMetadata.swift"; sourceTree = "<group>"; };
E22E8786289DDF4C00E51191 /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; };
E22E8788289DDF5700E51191 /* Page+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Metadata.swift"; sourceTree = "<group>"; };
E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+LocalizedMetadata.swift"; sourceTree = "<group>"; };
E22E8794289E81D700E51191 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = "<group>"; };
E22E8797289EA42C00E51191 /* FileProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProcessor.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>"; };
@ -106,11 +82,12 @@
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>"; };
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailInfo.swift; sourceTree = "<group>"; };
E22E87B5289FF67B00E51191 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
E253C86828AFD86E0076B6D0 /* FileAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccess.swift; sourceTree = "<group>"; };
E253C86A28AFE0980076B6D0 /* Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = "<group>"; };
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadataProvider.swift; sourceTree = "<group>"; };
E253C87628B767D50076B6D0 /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.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>"; };
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>"; };
@ -120,10 +97,7 @@
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>"; };
E2C5A5EB28A055E900102A25 /* SiteElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteElement.swift; sourceTree = "<group>"; };
E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageContainer.swift; sourceTree = "<group>"; };
E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionCleanTemplate.swift; sourceTree = "<group>"; };
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadata.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>"; };
@ -135,7 +109,7 @@
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 /* ErrorOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorOutput.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 */
@ -171,17 +145,12 @@
E22E8761289D84C300E51191 /* WebsiteGenerator */ = {
isa = PBXGroup;
children = (
E2F8FA2E28AD44FF00632026 /* Generic */,
E22E8762289D84C300E51191 /* main.swift */,
E22E87A1289F0BF000E51191 /* Content */,
E253C87828B80AAF0076B6D0 /* Files */,
E2F8FA2E28AD44FF00632026 /* Content */,
E22E87A2289F0C6200E51191 /* Generators */,
E2C5A5D328A0222B00102A25 /* Templates */,
E22E8799289EE02300E51191 /* Extensions */,
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */,
E22E8797289EA42C00E51191 /* FileProcessor.swift */,
E22E8777289DA0E100E51191 /* GenerationError.swift */,
E2F8FA2728ACD84400632026 /* VideoType.swift */,
E22E8794289E81D700E51191 /* FileSystem.swift */,
);
path = WebsiteGenerator;
sourceTree = "<group>";
@ -192,36 +161,18 @@
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>";
};
E22E87A1289F0BF000E51191 /* Content */ = {
isa = PBXGroup;
children = (
E2C5A5EB28A055E900102A25 /* SiteElement.swift */,
E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */,
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */,
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */,
E22E8786289DDF4C00E51191 /* Page.swift */,
E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */,
E22E8788289DDF5700E51191 /* Page+Metadata.swift */,
E22E8781289DCCB600E51191 /* Section.swift */,
E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */,
E22E8769289D84FD00E51191 /* Section+Metadata.swift */,
E22E8779289DA9F900E51191 /* Site.swift */,
E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */,
E22E877E289DC11F00E51191 /* Site+Metadata.swift */,
E22E87B5289FF67B00E51191 /* Metadata.swift */,
);
path = Content;
sourceTree = "<group>";
};
E22E87A2289F0C6200E51191 /* Generators */ = {
isa = PBXGroup;
children = (
E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */,
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */,
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */,
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */,
E22E87A3289F0C7000E51191 /* SiteGenerator.swift */,
@ -233,6 +184,19 @@
path = Generators;
sourceTree = "<group>";
};
E253C87828B80AAF0076B6D0 /* Files */ = {
isa = PBXGroup;
children = (
E2F8FA3728AE27A500632026 /* ContentError.swift */,
E2F8FA3928AE313A00632026 /* ValidationLog.swift */,
E253C87928B810090076B6D0 /* ImageOutput.swift */,
E253C87B28B8BFB80076B6D0 /* FileSystem.swift */,
E253C87628B767D50076B6D0 /* MediaType.swift */,
E2F8FA2728ACD84400632026 /* VideoType.swift */,
);
path = Files;
sourceTree = "<group>";
};
E2C5A5D328A0222B00102A25 /* Templates */ = {
isa = PBXGroup;
children = (
@ -279,20 +243,17 @@
path = Filled;
sourceTree = "<group>";
};
E2F8FA2E28AD44FF00632026 /* Generic */ = {
E2F8FA2E28AD44FF00632026 /* Content */ = {
isa = PBXGroup;
children = (
E253C86A28AFE0980076B6D0 /* Context.swift */,
E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */,
E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */,
E2F8FA2F28AD450B00632026 /* PageState.swift */,
E2F8FA3328AD6F3400632026 /* Element.swift */,
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */,
E2F8FA3728AE27A500632026 /* ContentError.swift */,
E2F8FA3928AE313A00632026 /* ErrorOutput.swift */,
E253C86828AFD86E0076B6D0 /* FileAccess.swift */,
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */,
);
path = Generic;
path = Content;
sourceTree = "<group>";
};
/* End PBXGroup section */
@ -363,60 +324,47 @@
files = (
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */,
E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */,
E2F8FA3A28AE313A00632026 /* ErrorOutput.swift in Sources */,
E22E876E289D868100E51191 /* Site+LocalizedMetadata.swift in Sources */,
E2F8FA3A28AE313A00632026 /* ValidationLog.swift in Sources */,
E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */,
E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */,
E2F8FA2D28AD2F5300632026 /* GenericMetadata.swift in Sources */,
E22E8798289EA42C00E51191 /* FileProcessor.swift in Sources */,
E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */,
E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */,
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */,
E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */,
E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */,
E22E876A289D84FD00E51191 /* Section+Metadata.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 */,
E22E8793289E7EC700E51191 /* Page+LocalizedMetadata.swift in Sources */,
E22E877D289DBA0A00E51191 /* OverviewSectionGenerator.swift in Sources */,
E22E8782289DCCB600E51191 /* Section.swift in Sources */,
E22E877F289DC11F00E51191 /* Site+Metadata.swift in Sources */,
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */,
E22E87A4289F0C7000E51191 /* SiteGenerator.swift in Sources */,
E22E87AC289F1D3700E51191 /* Template.swift in Sources */,
E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */,
E2F8FA3028AD450B00632026 /* PageState.swift in Sources */,
E22E8784289DCD5E00E51191 /* Section+LocalizedMetadata.swift in Sources */,
E22E8789289DDF5700E51191 /* Page+Metadata.swift in Sources */,
E2C5A5EC28A055E900102A25 /* SiteElement.swift in Sources */,
E253C87728B767D50076B6D0 /* MediaType.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 */,
E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */,
E22E8778289DA0E100E51191 /* GenerationError.swift in Sources */,
E2D55ED928A1BAD800B9453E /* LanguageContainer.swift in Sources */,
E2C5A5E928A0451C00102A25 /* LocalizedSiteTemplate.swift in Sources */,
E2C5A5E128A0373300102A25 /* ThumbnailTemplate.swift in Sources */,
E22E8795289E81D700E51191 /* FileSystem.swift in Sources */,
E22E8795289E81D700E51191 /* URL+Extensions.swift in Sources */,
E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */,
E22E8763289D84C300E51191 /* main.swift in Sources */,
E253C86B28AFE0980076B6D0 /* Context.swift in Sources */,
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */,
E22E877A289DA9F900E51191 /* Site.swift in Sources */,
E2F8FA3228AD456C00632026 /* GenericMetadata+Localized.swift in Sources */,
E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */,
E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */,
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */,
E22E8787289DDF4C00E51191 /* Page.swift in Sources */,
E253C87A28B810090076B6D0 /* ImageOutput.swift in Sources */,
E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */,
E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */,
E253C86928AFD86E0076B6D0 /* FileAccess.swift in Sources */,
E253C88128B8FBFF0076B6D0 /* NSSize+Extensions.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@ -0,0 +1,78 @@
<?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

@ -10,5 +10,13 @@
<integer>0</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>E22E875E289D84C300E51191</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict>
</plist>

View File

@ -41,9 +41,9 @@ extension Element {
/**
The file name of the link preview image.
- Note: The image must be located in the element folder.
- Note: If `nil` is specified, then the (localized) thumbnail is used.
- Note: If `nil` is specified, then the (localized) thumbnail is used, if available.
*/
let linkPreviewImage: String
let linkPreviewImage: String?
/**
The description text for the link preview.
@ -63,10 +63,16 @@ extension Element {
The text on the back navigation link of **contained** elements.
This text does not appear on the section page, but on the pages contained within the section.
- Note: If this property is not specified, then the `defaultBackLinkText` is used
*/
let backLinkText: String
/**
The text on the back navigation link of the **parent** element.
This text appears on the section page, but not on the pages contained within the section.
*/
let parentBackLinkText: String
/**
The text to show as a title for placeholder boxes
@ -118,8 +124,7 @@ extension Element {
extension Element.LocalizedMetadata {
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata, with context: Context) {
let validation = context.validation
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata) {
// Go through all elements and check them for completeness
// In the end, check that all required elements are present
var isComplete = true
@ -127,43 +132,43 @@ extension Element.LocalizedMetadata {
isComplete = false
}
let source = "root"
self.language = validation
self.language = log
.required(data.language, name: "language", source: source)
.ifNil(markAsIncomplete) ?? ""
self.title = validation
self.title = log
.required(data.title, name: "title", source: source)
.ifNil(markAsIncomplete) ?? ""
self.subtitle = data.subtitle
self.description = data.description
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
self.linkPreviewImage = validation
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source) ?? ""
self.linkPreviewImage = log
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
self.linkPreviewDescription = validation
self.linkPreviewDescription = log
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
self.backLinkText = validation
self.backLinkText = log
.required(data.backLinkText, name: "backLinkText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.placeholderTitle = validation
self.parentBackLinkText = "" // Root has no parent
self.placeholderTitle = log
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
.ifNil(markAsIncomplete) ?? ""
self.placeholderText = validation
self.placeholderText = log
.required(data.placeholderText, name: "placeholderText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.titleSuffix = data.titleSuffix
self.thumbnailSuffix = validation.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
self.cornerText = validation.unused(data.cornerText, "cornerText", source: source)
self.externalUrl = validation.unexpected(data.externalUrl, name: "externalUrl", source: source)
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)
guard isComplete else {
return nil
}
}
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata, with context: Context) {
let validation = context.validation
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata) {
// Go through all elements and check them for completeness
// In the end, check that all required elements are present
var isComplete = true
@ -171,20 +176,21 @@ extension Element.LocalizedMetadata {
isComplete = false
}
self.language = parent.language
self.title = validation
self.title = log
.required(data.title, name: "title", source: source)
.ifNil(markAsIncomplete) ?? ""
self.subtitle = data.subtitle
self.description = data.description
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
self.linkPreviewImage = validation
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source) ?? ""
self.linkPreviewImage = log
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
self.linkPreviewDescription = validation
self.linkPreviewDescription = log
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = validation.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
self.moreLinkText = log.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
self.backLinkText = data.backLinkText ?? parent.backLinkText
self.parentBackLinkText = parent.backLinkText
self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle
self.placeholderText = data.placeholderText ?? parent.placeholderText
self.titleSuffix = data.titleSuffix

View File

@ -0,0 +1,429 @@
import Foundation
struct Element {
static let overviewItemCountDefault = 6
/**
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 url where the site will be deployed.
This value is required to build absolute links for link previews.
- Note: Only the root level value is used.
- Note: The path does not need to contain a trailing slash.
*/
let deployedBaseUrl: 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.author = log.required(metadata.author, name: "author", source: source) ?? "author"
self.topBarTitle = log
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
self.deployedBaseUrl = log
.required(metadata.deployedBaseUrl, name: "deployedBaseUrl", source: source) ?? "https://example.com"
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)
} ?? []
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.author = metadata.author ?? parent.author
self.topBarTitle = log
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
self.deployedBaseUrl = log
.unused(metadata.deployedBaseUrl, "deployedBaseUrl", source: source) ?? parent.deployedBaseUrl
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 = metadata.externalFiles ?? []
self.requiredFiles = Set((metadata.requiredFiles ?? []).map { path + "/" + $0 })
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)
}
try self.readElements(in: folder, source: path)
}
}
// MARK: Paths
extension Element {
var containsElements: Bool {
!elements.isEmpty
}
var hasNestingElements: Bool {
elements.contains { $0.containsElements }
}
var sortedItems: [Element] {
if useManualSorting {
return elements.sorted { $0.sortIndex! < $1.sortIndex! }
}
return elements.sorted { $0.date! > $1.date! }
}
/**
The url of the top-level section of the element.
*/
func sectionUrl(for language: String) -> String {
path.components(separatedBy: "/").first! + "/\(language).html"
}
/**
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 {
guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else {
return filePath
}
return "\(path)/\(filePath)"
}
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 {
fatalError()
}
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 {
path != "" ? "\(path)/\(language).html" : "\(language).html"
}
/**
Get the next language to switch to with the language button.
*/
func nextLanguage(for languageIdentifier: String) -> String? {
let langs = languages.map { $0.language }
guard let index = langs.firstIndex(of: languageIdentifier) else {
return nil
}
for i in 1..<langs.count {
let next = langs[(index + i) % langs.count]
guard hasContent(for: next) else {
continue
}
guard next != languageIdentifier 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 {
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

@ -100,6 +100,14 @@ struct GenericMetadata {
*/
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.
*/
@ -122,7 +130,8 @@ extension GenericMetadata: Codable {
.thumbnailStyle,
.useManualSorting,
.overviewItemCount,
.languages
.useCustomHeader,
.languages,
]
}
@ -136,12 +145,13 @@ extension GenericMetadata {
/**
Decode metadata in a folder.
- Parameter data: The binary data of the metadata file.
- Parameter source: The path to the metadata file, relative to the source root
- Parameter context: The context for the element (validation, file access, etc.)
- Note: The decoding routine also checks for unknown properties, and writes them to the output.
- Note: Uses global objects
*/
init?(source: String, with context: Context) throws {
guard let data = try context.fileSystem.loadDataContent(inputPath: source) else {
init?(source: String) {
guard let data = files.dataOfOptionalFile(atPath: source, source: source) else {
return nil
}
@ -154,20 +164,21 @@ extension GenericMetadata {
// Only one key means we are decoding the generic metadata
guard keys.count > 1 else {
if !knownKeys.contains(key.stringValue) {
context.validation.unknown(property: key.stringValue, source: source)
log.unknown(property: key.stringValue, source: source)
}
return key
}
// Two levels means we're decoding the localized metadata
if !knownLocalizedKeys.contains(key.stringValue) {
context.validation.unknown(property: key.stringValue, source: source)
log.unknown(property: key.stringValue, source: source)
}
return key
}
do {
self = try decoder.decode(from: data)
} catch {
context.validation.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
print("Here \(data)")
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
return nil
}
}

View File

@ -1,119 +0,0 @@
import Foundation
protocol LanguageIdentifiable {
var languageIdentifier: String { get }
var title: String { get }
}
protocol LanguageContainer {
associatedtype LocalizedContainer: LanguageIdentifiable
var languages: [LocalizedContainer] { get }
}
protocol LocalizedMetadataContainer {
associatedtype MetadataType: LanguageContainer
var metadata: MetadataType { get }
func hasContent(for language: String) -> Bool
}
// MARK: Default implementations
extension LocalizedMetadataContainer {
func hasContent(for language: String) -> Bool {
true
}
}
// MARK: Extensions
extension LocalizedMetadataContainer {
func localized(for language: String) -> MetadataType.LocalizedContainer {
metadata.localized(for: language)
}
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String {
localized(for: language).title
}
func nextLanguage(for languageIdentifier: String) -> String? {
let langs = metadata.languages.map { $0.languageIdentifier }
guard let index = langs.firstIndex(of: languageIdentifier) else {
return nil
}
for i in 1..<langs.count {
let next = langs[(index + i) % langs.count]
guard hasContent(for: next) else {
continue
}
guard next != languageIdentifier else {
return nil
}
return next
}
return nil
}
}
extension LanguageContainer {
var languageIdentifiers: [String] {
languages.map { $0.languageIdentifier }
}
#warning("Throw better error for missing language")
func localized(for language: String) -> LocalizedContainer {
languages.first { $0.languageIdentifier == language }!
}
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String {
localized(for: language).title
}
}
extension LocalizedMetadataContainer where Self: SiteElement, Self.MetadataType.LocalizedContainer: LinkPreviewMetadataProvider {
private func linkPreviewImageFileName(for language: String) -> String? {
if let fileName = localized(for: language).linkPreview?.image {
return fileName
}
// Check for the existence of a localized thumbnail
let fileName = Self.thumbnailFileNameLocalized(for: language)
if inputFolder.appendingPathComponent(fileName).exists {
return fileName
}
let defaultThumbnail = Self.defaultThumbnailFileName
if inputFolder.appendingPathComponent(defaultThumbnail).exists {
return defaultThumbnail
}
return nil
}
func linkPreviewImage(for language: String) -> String? {
guard let fileName = linkPreviewImageFileName(for: language) else {
return nil
}
return "/\(path)/\(fileName)"
}
}

View File

@ -1,41 +0,0 @@
import Foundation
/**
Localized configuration data for link previews of site elements.
This struct is embedded in localized metadata and intended to be filled in the JSON source.
*/
struct LinkPreviewMetadata {
/**
The title to use for the link preview.
If `nil` is specified, then the localized element `title` is used.
*/
let title: String?
/**
The file name of the link preview image.
- Note: The image must be located in the element folder.
- Note: If `nil` is specified, then the (localized) thumbnail is used.
*/
let image: String?
/**
The description text for the link preview.
- Note: If `nil` is specified, then first the (localized) element `subtitle` is used.
If this is `nil` too, then the localized `description` of the element is used.
*/
let description: String?
}
extension LinkPreviewMetadata: Codable { }
extension LinkPreviewMetadata {
static var initial: LinkPreviewMetadata {
.init(title: nil,
image: nil,
description: "The page description for link previews")
}
}

View File

@ -1,23 +0,0 @@
import Foundation
protocol LinkPreviewMetadataProvider {
var linkPreview: LinkPreviewMetadata? { get }
var title: String { get }
var subtitle: String? { get }
var description: String { get }
}
extension LinkPreviewMetadataProvider {
var linkPreviewTitle: String {
linkPreview?.title ?? title
}
var linkPreviewDescription: String {
linkPreview?.description ?? subtitle ?? description
}
}

View File

@ -1,29 +0,0 @@
import Foundation
protocol Metadata: Codable {
static var fileName: String { get }
static var initial: Self { get }
}
extension Metadata {
static func url(in folder: URL) -> URL {
folder.appendingPathComponent(fileName)
}
static func exists(in folder: URL) -> Bool {
url(in: folder).exists
}
init?(in folder: URL) throws {
let metadataUrl = Self.url(in: folder)
guard metadataUrl.exists else {
try Self.initial.writeJSON(to: metadataUrl)
print("Created metadata in \(folder)")
return nil
}
try self.init(decodeFrom: metadataUrl)
}
}

View File

@ -1,62 +0,0 @@
import Foundation
extension Page {
struct LocalizedMetadata {
let id: String
let title: String
#warning("Generate title suffix")
let titleSuffix: String?
let linkPreview: LinkPreviewMetadata?
let subtitle: String?
#warning("Generate thumbnail suffix")
let thumbnailSuffix: String?
let cornerText: String?
/**
The external url to use instead of automatically generating the page.
This property can be used for links to other parts of the site, like additional services.
It can also be set to manually write a page.
*/
let externalUrl: String?
}
}
extension Page.LocalizedMetadata: Codable {
}
extension Page.LocalizedMetadata: LanguageIdentifiable {
var languageIdentifier: String {
id
}
}
extension Page.LocalizedMetadata {
static var initial: Page.LocalizedMetadata {
.init(id: "en",
title: "Page title",
titleSuffix: nil,
linkPreview: .initial,
subtitle: "Some text below the title",
thumbnailSuffix: "Project",
cornerText: nil,
externalUrl: nil)
}
}
extension Page.LocalizedMetadata: LinkPreviewMetadataProvider {
var description: String { subtitle ?? title }
}

View File

@ -1,107 +0,0 @@
import Foundation
extension Page {
struct Metadata {
let date: Date
let endDate: Date?
let author: String?
let isDraft: Bool
let sortIndex: Int?
let languages: [LocalizedMetadata]
#warning("Add hideFromOverview property")
let requiredFiles: [String]
/**
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
#warning("Add files for which errors are ignored when missing")
}
}
extension Page.Metadata: Metadata {
static let fileName = "page.json"
static var initial: Page.Metadata {
.init(
date: .now,
endDate: .now,
author: nil,
isDraft: true,
sortIndex: 0,
languages: [.initial],
requiredFiles: [],
useCustomHeader: false)
}
}
extension Page.Metadata: LanguageContainer {
}
extension Page.Metadata: Codable {
enum CodingKeys: CodingKey {
case date
case endDate
case author
case isDraft
case sortIndex
case languages
case requiredFiles
case useCustomHeader
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let dateString = Page.metadataDateFormatter.string(from: date)
try container.encode(dateString, forKey: .date)
if let date = endDate {
let endDateString = Page.metadataDateFormatter.string(from: date)
try container.encode(endDateString, forKey: .endDate)
}
try container.encodeIfPresent(author, forKey: .author)
try container.encode(isDraft, forKey: .isDraft)
try container.encodeIfPresent(sortIndex, forKey: .sortIndex)
try container.encode(languages, forKey: .languages)
if !requiredFiles.isEmpty {
try container.encode(requiredFiles, forKey: .requiredFiles)
}
if useCustomHeader {
try container.encode(true, forKey: .useCustomHeader)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let dateString = try container.decode(String.self, forKey: .date)
self.date = try Page.metadataDateFormatter.date(from: dateString)
.unwrap(or: .invalidDateInPageMetadata(dateString))
self.author = try container.decodeIfPresent(String.self, forKey: .author)
self.languages = try container.decode([Page.LocalizedMetadata].self, forKey: .languages)
self.isDraft = try container.decodeIfPresent(Bool.self, forKey: .isDraft) ?? false
self.sortIndex = try container.decodeIfPresent(Int.self, forKey: .sortIndex)
if let endDateString = try container.decodeIfPresent(String.self, forKey: .endDate) {
self.endDate = try Page.metadataDateFormatter.date(from: endDateString)
.unwrap(or: .invalidDateInPageMetadata(endDateString))
} else {
self.endDate = nil
}
self.requiredFiles = try container.decodeIfPresent([String].self, forKey: .requiredFiles) ?? []
self.useCustomHeader = try container.decodeIfPresent(Bool.self, forKey: .useCustomHeader) ?? false
}
}

View File

@ -1,83 +0,0 @@
import Foundation
struct Page {
let metadata: Metadata
/// The input folder where the page data is stored
let inputFolder: URL
let path: String
init?(folder: URL, path: String) throws {
self.path = path
guard let metadata = try Metadata(in: folder) else {
return nil
}
self.inputFolder = folder
self.metadata = metadata
}
}
extension Page {
static let metadataDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yy"
return df
}()
}
extension Page: SiteElement {
var sortIndex: Int? {
metadata.sortIndex
}
var sortDate: Date? {
metadata.date
}
var elements: [SiteElement] { [] }
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
var isExternalPage: Bool {
metadata.languages.contains { $0.externalUrl != nil }
}
func fullPageUrl(for language: String) -> String {
localized(for: language).externalUrl ?? "\(path)/\(language).html"
}
}
extension Page: LocalizedMetadataContainer {
/**
Get the url of the content markdown file for a language.
To check if the file also exists, use `existingContentUrl(for:)`
*/
func contentUrl(for language: String) -> URL {
inputFolder.appendingPathComponent("\(language).md")
}
/**
Get the url of existing markdown content for a language.
*/
func existingContentUrl(for language: String) -> URL? {
let url = contentUrl(for: language)
guard url.exists else {
return nil
}
return url
}
func hasContent(for language: String) -> Bool {
existingContentUrl(for: language) != nil
}
}

View File

@ -1,65 +0,0 @@
import Foundation
extension Section {
struct LocalizedMetadata {
let id: String
let title: String
let subtitle: String?
let description: String
/**
The text on the link to show the section page when previewing multiple sections on an overview page.
*/
let moreLinkTitle: String
/**
An optional text to display in the corner of the section thumbnail.
Can be used to show things like "new", "draft", etc.
*/
let cornerText: String?
let linkPreview: LinkPreviewMetadata?
/**
The text on the back navigation link of contained elements.
This text does not appear on the section page, but on the pages contained within the section.
*/
let backLinkText: String?
}
}
extension Section.LocalizedMetadata: Codable {
}
extension Section.LocalizedMetadata: LanguageIdentifiable {
var languageIdentifier: String {
id
}
}
extension Section.LocalizedMetadata {
static var initial: Section.LocalizedMetadata {
.init(id: "en",
title: "Section title",
subtitle: "Tag line below the title",
description: "The short text below the tagline on the section overview page",
moreLinkTitle: "More section items",
cornerText: nil,
linkPreview: .initial,
backLinkText: "Back to section")
}
}
extension Section.LocalizedMetadata: LinkPreviewMetadataProvider {
}

View File

@ -1,79 +0,0 @@
import Foundation
extension Section {
static let defaultSectionOverviewItemCount = 6
struct Metadata {
let thumbnailStyle: ThumbnailStyle
let sortByMostRecent: Bool
let sortIndex: Int?
let date: Date?
let languages: [LocalizedMetadata]
let sectionOverviewItemCount: Int
}
}
extension Section.Metadata: Metadata {
static let fileName = "section.json"
static var initial: Section.Metadata {
.init(thumbnailStyle: .large,
sortByMostRecent: true,
sortIndex: nil,
date: nil,
languages: [.initial],
sectionOverviewItemCount: 6)
}
}
extension Section.Metadata: LanguageContainer {
}
extension Section.Metadata: Codable {
enum CodingKeys: CodingKey {
case thumbnailStyle
case sortByMostRecent
case sortIndex
case date
case languages
case sectionOverviewItemCount
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(thumbnailStyle, forKey: .thumbnailStyle)
try container.encode(sortByMostRecent, forKey: .sortByMostRecent)
try container.encodeIfPresent(sortIndex, forKey: .sortIndex)
try container.encode(languages, forKey: .languages)
if let date = date {
let dateString = Page.metadataDateFormatter.string(from: date)
try container.encode(dateString, forKey: .date)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.thumbnailStyle = try container.decode(ThumbnailStyle.self, forKey: .thumbnailStyle)
self.sortByMostRecent = try container.decode(Bool.self, forKey: .sortByMostRecent)
self.sortIndex = try container.decodeIfPresent(Int.self, forKey: .sortIndex)
self.languages = try container.decode([Section.LocalizedMetadata].self, forKey: .languages)
if let dateString = try container.decodeIfPresent(String.self, forKey: .date) {
self.date = try Page.metadataDateFormatter.date(from: dateString)
.unwrap(or: .invalidDateInPageMetadata(dateString))
} else {
self.date = nil
}
self.sectionOverviewItemCount = try container
.decodeIfPresent(Int.self, forKey: .sectionOverviewItemCount) ?? Section.defaultSectionOverviewItemCount
}
}

View File

@ -1,74 +0,0 @@
import Foundation
struct Section {
let metadata: Metadata
let inputFolder: URL
let elements: [SiteElement]
/// The path to get to the section from the root folder (no leading slash)
let path: String
var folderName: String {
inputFolder.lastPathComponent
}
var sortedItems: [SiteElement] {
guard metadata.sortByMostRecent else {
return elements.sorted { $0.sortIndex! < $1.sortIndex! }
}
return elements.sorted { $0.sortDate! > $1.sortDate! }
}
init?(folder: URL, path: String) throws {
self.path = path
guard let metadata = try Metadata(in: folder) else {
return nil
}
self.metadata = metadata
self.inputFolder = folder
let elements: [SiteElement] = try FileSystem.folders(in: folder)
.compactMap {
let sectionPath = "\(path)/\($0.lastPathComponent)"
if Page.Metadata.exists(in: $0) {
return try Page(folder: $0, path: sectionPath)
}
if Section.Metadata.exists(in: $0) {
return try Section(folder: $0, path: sectionPath)
}
return nil
}
if metadata.sortByMostRecent {
self.elements = elements.sorted { $0.sortDate! > $1.sortDate! }
} else {
self.elements = elements.sorted { $0.sortIndex! < $1.sortIndex! }
}
#warning("Verify that all sort indices or sort dates are present")
print("Section \(folderName): \(elements.count) pages")
}
}
extension Section: SiteElement {
var sortIndex: Int? {
metadata.sortIndex
}
var sortDate: Date? {
metadata.date
}
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
func backLinkText(for language: String) -> String? {
localized(for: language).backLinkText
}
}
extension Section: LocalizedMetadataContainer {
}

View File

@ -1,71 +0,0 @@
import Foundation
extension Site {
struct LocalizedMetadata {
let languageIdentifier: String
let linkPreview: LinkPreviewMetadata?
let title: String
let subtitle: String?
let description: String
/**
The text on the back navigation link of contained elements.
This text does not appear on the section page, but on the pages contained within the section.
*/
let backLinkText: String?
/**
The back text to use for element which don't specify a `backLinkText` themselves.
*/
let defaultBackLinkText: String
/**
The text to show as a title for placeholder boxes
Placeholders are included in missing pages.
*/
let placeholderTitle: String
/**
The text to show as a description for placeholder boxes
Placeholders are included in missing pages.
*/
let placeholderText: String
}
}
extension Site.LocalizedMetadata: Codable {
}
extension Site.LocalizedMetadata: LanguageIdentifiable {
}
extension Site.LocalizedMetadata {
static var initial: Site.LocalizedMetadata {
.init(
languageIdentifier: "en",
linkPreview: .initial,
title: "Website name on front page",
subtitle: "Tag line on front page",
description: "Some text below the tag line on the title page",
backLinkText: "Back to start",
defaultBackLinkText: "Back",
placeholderTitle: "Content missing",
placeholderText: "This page is incomplete. Content will be added in the coming days.")
}
}
extension Site.LocalizedMetadata: LinkPreviewMetadataProvider {
}

View File

@ -1,48 +0,0 @@
import Foundation
extension Site {
struct Metadata {
let author: String
let ignoredSubFolders: Set<String>
let topBarTitle: String?
/**
The url where the site will be deployed.
This value is required to build absolute links for link previews.
- Note: The path does not need to contain a trailing slash.
*/
let deployedBaseUrl: String
let languages: [LocalizedMetadata]
static func write(to url: URL) throws {
try Metadata.initial.writeJSON(to: url)
}
}
}
extension Site.Metadata: LanguageContainer {
}
extension Site.Metadata: Codable {
}
extension Site.Metadata: Metadata {
static let fileName = "site.json"
static var initial: Self {
.init(author: "Author",
ignoredSubFolders: ["templates"],
topBarTitle: "<b>Title</b>",
deployedBaseUrl: "http://example.com",
languages: [.initial])
}
}

View File

@ -1,54 +0,0 @@
import Foundation
struct Site {
static let linkPreviewDesiredImageWidth = 1600
let elements: [SiteElement]
let metadata: Metadata
let inputFolder: URL
init?(folder: URL) throws {
self.inputFolder = folder
guard let metadata = try Metadata(in: folder) else {
return nil
}
guard !metadata.languages.isEmpty else {
throw GenerationError.invalidLanguageSpecification("No languages specified in site.json")
}
self.metadata = metadata
self.elements = try FileSystem.folders(in: folder)
.filter { !metadata.ignoredSubFolders.contains($0.lastPathComponent) }
.compactMap { sectionUrl in
return try Section(
folder: sectionUrl, path: sectionUrl.lastPathComponent)
}
print("Loaded site with \(elements.count) sections and \(metadata.languages.count) languages")
// Create example metadata
//_ = try? Page.Metadata(in: folder)
//_ = try? Section.Metadata(in: folder)
}
}
extension Site: LocalizedMetadataContainer {
}
extension Site: SiteElement {
var sortIndex: Int? { 0 }
var sortDate: Date? { nil }
var path: String { "" }
func cornerText(for language: String) -> String? { nil }
func backLinkText(for language: String) throws -> String? {
localized(for: language).backLinkText
}
}

View File

@ -1,199 +0,0 @@
import Foundation
protocol SiteElement {
/**
The sort index for the element when manual sorting is specified for the parent.
- Note: Elements are sorted in ascending order.
*/
var sortIndex: Int? { get }
/**
The date used for sorting of the element, if automatic sorting is specified by the parent.
- Note: Elements are sorted by newest first.
*/
var sortDate: Date? { get }
/**
The path to the element's folder in the source hierarchy (without a leading slash).
*/
var path: String { get }
/**
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.
*/
var inputFolder: URL { get }
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String
/**
The optional text to display in a thumbnail corner.
- Note: This text is only displayed for large thumbnails.
*/
func cornerText(for language: String) -> String?
/**
The url to the element in the given language.
If the `externalUrl` property is not set for the page metadata in the given language, then the standard path is returned.
- If this value starts with a slash, it is considered an absolute url to the same domain
- If the value starts with `http://` or `https://` it is considered an external url
- Otherwise the value is treated as a path from the root of the site.
*/
func fullPageUrl(for language: String) -> String
/**
All elements contained within the element.
If the element is a section, then this property contains the pages within.
*/
var elements: [SiteElement] { get }
func backLinkText(for language: String) throws -> String?
}
extension SiteElement {
func fullPageUrl(for language: String) -> String {
localizedPath(for: language)
}
}
extension SiteElement {
/**
The id of the section to which this element contains.
This property is used to highlight the active section in the top bar.
The section id is the folder name of the top-level section
*/
var sectionId: String {
path.components(separatedBy: "/").first!
}
static var defaultThumbnailFileName: String { "thumbnail.jpg" }
static func thumbnailFileNameLocalized(for language: String) -> String {
defaultThumbnailFileName.insert("-\(language)", beforeLast: ".")
}
var containedFolder: String {
inputFolder.lastPathComponent
}
var containsElements: Bool {
!elements.isEmpty
}
var hasNestingElements: Bool {
elements.contains { $0.containsElements }
}
/**
Get the full path of the thumbnail image for the language (relative to the root folder).
*/
func thumbnailFilePath(for language: String) -> String {
let specificImageName = Self.thumbnailFileNameLocalized(for: language)
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
guard specificImageUrl.exists else {
return "\(path)/\(Self.defaultThumbnailFileName)"
}
return "\(path)/\(specificImageName)"
}
/**
Gets the thumbnail image for the element.
If a localized thumbnail exists, then this image name is returned.
*/
func thumbnailName(for language: String) -> String {
let specificImageName = "thumbnail-\(language).jpg"
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
guard specificImageUrl.exists else {
return "\(inputFolder.lastPathComponent)/thumbnail.jpg"
}
return "\(inputFolder.lastPathComponent)/\(specificImageName)"
}
/**
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 {
guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else {
return filePath
}
return "\(path)/\(filePath)"
}
func backLinkText(for language: String) throws -> String? { nil }
/**
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 {
path != "" ? "\(path)/\(language).html" : "\(language).html"
}
func relativePathToFileWithPath(_ filePath: String) -> String {
guard path != "" else {
return filePath
}
guard filePath.hasPrefix(path) else {
return filePath
}
return filePath.replacingOccurrences(of: path + "/", with: "")
}
private var additionalHeadContentUrl: URL {
inputFolder.appendingPathComponent("head.html")
}
var hasAdditionalHeadContent: Bool {
additionalHeadContentUrl.exists
}
func customHeadContent() throws -> String? {
let url = additionalHeadContentUrl
guard url.exists else {
return nil
}
return try wrap(.failedToOpenFile(url.path)) {
try String(contentsOf: url)
}
}
private var additionalFooterContentUrl: URL {
inputFolder.appendingPathComponent("footer.html")
}
var hasAdditionalFooterContent: Bool {
additionalFooterContentUrl.exists
}
func customFooterContent() throws -> String? {
let url = additionalFooterContentUrl
guard url.exists else {
return nil
}
return try wrap(.failedToOpenFile(url.path)) {
try String(contentsOf: url)
}
}
}
extension SiteElement {
func printContents() {
print(path)
elements.forEach { $0.printContents() }
}
}

View File

@ -0,0 +1,9 @@
import Foundation
extension Data {
func createFolderAndWrite(to url: URL) throws {
try url.ensureParentFolderExistence()
try write(to: url)
}
}

View File

@ -0,0 +1,15 @@
import Foundation
import AppKit
extension NSImage {
func scaledDown(to size: NSSize) -> NSImage {
guard self.size.width > size.width else {
return self
}
return NSImage(size: size, flipped: false) { (resizedRect) -> Bool in
self.draw(in: resizedRect)
return true
}
}
}

View File

@ -0,0 +1,28 @@
import Foundation
extension NSSize {
func scaledDown(to desiredWidth: CGFloat) -> NSSize {
if width == desiredWidth {
return self
}
if width < desiredWidth {
// Don't scale larger
return self
}
let height = height * desiredWidth / width
return NSSize(width: desiredWidth, height: height)
}
}
extension NSSize {
var ratio: CGFloat {
guard height != 0 else {
return 0
}
return width / height
}
}

View File

@ -32,8 +32,16 @@ extension String {
components(separatedBy: separator).last!
}
/**
Insert the new content before the last occurence of the specified separator.
If the separator does not appear in the string, then the new content is simply appended.
*/
func insert(_ content: String, beforeLast separator: String) -> String {
let parts = components(separatedBy: separator)
guard parts.count > 1 else {
return self + content
}
return parts.dropLast().joined(separator: separator) + content + separator + parts.last!
}
@ -53,3 +61,10 @@ extension Substring {
.components(separatedBy: end).first!
}
}
extension String {
func createFolderAndWrite(to url: URL) throws {
try data(using: .utf8)!.createFolderAndWrite(to: url)
}
}

View File

@ -0,0 +1,38 @@
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,306 +0,0 @@
import Foundation
#if canImport(AppKit)
import AppKit
#endif
final class FileProcessor {
enum MediaType {
case image
case video
case file
}
func mediaType(forExtension fileExtension: String) -> MediaType {
if supportedImageExtensions[fileExtension] != nil {
return .image
}
if supportedVideoExtensions.contains(fileExtension) {
return .video
}
return .file
}
private let supportedImageExtensions: [String : NSBitmapImageRep.FileType] = [
"jpg" : .jpeg,
"jpeg" : .jpeg,
"png" : .png,
]
private let supportedVideoExtensions: Set<String> = [
"mp4", "mov"
]
struct ImageOutput: Hashable {
let source: String
let width: Int
let desiredHeight: Int?
var ratio: Float? {
guard let desiredHeight = desiredHeight else {
return nil
}
return Float(desiredHeight) / Float(width)
}
func hasSimilarRatio(as other: ImageOutput) -> Bool {
guard let other = other.ratio, let ratio = ratio else {
return true
}
return abs(other - ratio) < 0.1
}
}
let inputFolder: URL
let outputFolder: URL
/**
The files required by the site.
The content are the links to the files relative to the source root folder.
The files will be placed at the same path relative to the output folder
*/
private var requiredFiles: Set<String> = []
private var tasks: [String : ImageOutput] = [:]
init(inputFolder: URL, outputFolder: URL) {
self.inputFolder = inputFolder
self.outputFolder = outputFolder
}
// MARK: Files
/**
Add a file as required, so that it will be copied to the output directory.
*/
func require(file: String) {
requiredFiles.insert(file)
}
func copyRequiredFiles() throws {
var missingFiles = [String]()
for file in requiredFiles {
let sourceUrl = inputFolder.appendingPathComponent(file)
guard sourceUrl.exists else {
missingFiles.append(file)
continue
}
let destinationUrl = outputFolder.appendingPathComponent(file)
try FileSystem.copy(sourceUrl, to: destinationUrl)
}
}
// MARK: Images
@discardableResult
func requireImage(source: String, destination: String, width: Int, desiredHeight: Int? = nil, createDoubleVersion: Bool = false) throws -> NSSize {
let output = ImageOutput(
source: source,
width: width,
desiredHeight: desiredHeight)
return try requireImage(output,
for: destination,
createDoubleVersion: createDoubleVersion)
}
private func insert(_ image: ImageOutput, for destination: String) throws -> NSSize {
let sourceUrl = inputFolder.appendingPathComponent(image.source)
guard sourceUrl.exists else {
throw GenerationError.missingImage(sourceUrl.path)
}
guard let imageSize = NSImage(contentsOfFile: sourceUrl.path)?.size else {
let height = image.desiredHeight.unwrapped(CGFloat.init)
let width = CGFloat(image.width)
return .init(width: width, height: height ?? width / 16 * 9)
//throw GenerationError.failedToGenerateImage(sourceUrl.path)
}
let scaledSize = getScaledSize(of: imageSize, to: CGFloat(image.width))
guard let existing = tasks[destination] else {
//print("Image(\(image.width),\(image.desiredHeight ?? -1)) requested for \(destination)")
tasks[destination] = image
return scaledSize
}
guard existing.source == image.source else {
throw GenerationError.conflictingImageSources(
output: destination, in1: existing.source, in2: image.source)
}
guard existing.hasSimilarRatio(as: image) else {
throw GenerationError.conflictingImageRatios(
output: destination, in1: existing.source, in2: image.source)
}
if image.width > existing.width {
//print("Image(\(image.width),\(image.desiredHeight ?? -1)) requested for \(destination)")
tasks[destination] = image
}
return scaledSize
}
@discardableResult
func requireImage(_ image: ImageOutput, for destination: String, createDoubleVersion: Bool = false) throws -> NSSize {
let size = try insert(image, for: destination)
guard createDoubleVersion else {
return size
}
_ = try requireImage(
source: image.source,
destination: destination.insert("@2x", beforeLast: "."),
width: image.width * 2,
desiredHeight: image.desiredHeight.unwrapped { $0 * 2 } )
// Return 1x size
return size
}
func createImages() throws {
for (destination, image) in tasks {
try createImageIfNeeded(image, for: destination)
}
}
private func createImageIfNeeded(_ image: ImageOutput, for destination: String) throws {
let source = inputFolder.appendingPathComponent(image.source)
guard source.exists else {
throw GenerationError.missingImage(source.path)
}
let destination = outputFolder.appendingPathComponent(destination)
#warning("Check if source image has changed since last run")
guard !destination.exists else {
return
}
// Ensure that image file is supported
let ext = destination.pathExtension.lowercased()
guard supportedImageExtensions[ext] != nil else {
print("Copying file \(source.path)")
try FileSystem.copy(source, to: destination)
return
}
#if canImport(AppKit)
try createImage(
destination,
from: source,
with: CGFloat(image.width),
and: image.desiredHeight.unwrapped(CGFloat.init))
#else
throw GenerationError.failedToGenerateImage(destination.path)
#endif
}
#if canImport(AppKit)
private func createImage(_ destination: URL, from source: URL, with desiredWidth: CGFloat, and desiredHeight: CGFloat?) throws {
guard let sourceImage = NSImage(contentsOfFile: source.path) else {
print("Failed to load image \(source.path)")
throw GenerationError.failedToGenerateImage(source.path)
}
let destinationSize = getScaledSize(of: sourceImage.size, to: desiredWidth)
let scaledImage = scale(image: sourceImage, to: destinationSize)
let scaledSize = scaledImage.size
if abs(scaledImage.size.width - desiredWidth) > 2 {
print("[WARN] Image \(destination.path) scaled incorrectly (wanted width \(desiredWidth), is \(scaledSize.width))")
}
if abs(destinationSize.height - scaledImage.size.height) > 2 {
print("[WARN] Image \(destination.path) scaled incorrectly (wanted height \(destinationSize.height), is \(scaledSize.height))")
}
if let desiredHeight = desiredHeight {
let desiredRatio = desiredHeight / desiredWidth
let adjustedDesiredHeight = scaledSize.width * desiredRatio
if abs(adjustedDesiredHeight - scaledSize.height) > 5 {
print("[WARN] Image \(source.path): Desired height \(adjustedDesiredHeight) (actually \(desiredHeight)), got \(scaledSize.height) after reduction")
throw GenerationError.imageRatioMismatch(destination.path)
}
}
if scaledSize.width > desiredWidth {
print("[WARN] Image \(source.path) is too large (expected width \(desiredWidth), got \(scaledSize.width)")
}
try saveImage(scaledImage, atUrl: destination)
guard let savedImage = NSImage(contentsOfFile: destination.path) else {
throw GenerationError.failedToGenerateImage(source.path)
}
let savedSize = savedImage.size
if destination.lastPathComponent.hasSuffix("@2x.jpg") {
if abs(savedSize.height - destinationSize.height/2) > 2 || abs(savedSize.width - destinationSize.width/2) > 2 {
print("[WARN] Image \(destination.path) (2x): Expected (\(destinationSize.width/2),\(destinationSize.height/2)), got (\(savedSize.width),\(savedSize.height))")
}
} else if abs(savedSize.height - destinationSize.height) > 2 || abs(savedSize.width - destinationSize.width) > 2 {
print("[WARN] Image \(destination.path): Expected (\(destinationSize.width),\(destinationSize.height)), got (\(savedSize.width),\(savedSize.height))")
}
// print("Source (\(sourceWidth),\(sourceHeight))")
// print("Desired (\(desiredWidth),\(desiredHeight!))")
// print("Expected (\(expectedScaledWidth),\(expectedScaledHeight))")
// print("Scaled (\(scaledWidth),\(scaledImage.size.height))")
// print("Saved (\(savedWidth),\(savedHeight))")
// print(NSScreen.main!.backingScaleFactor)
}
private func saveImage(_ image: NSImage, atUrl url: URL) throws {
let ext = url.pathExtension.lowercased()
guard let type = supportedImageExtensions[ext] else {
print("No image type for \(url.path)")
throw GenerationError.failedToGenerateImage(url.path)
}
guard let tiff = image.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else {
print("Failed to get data for image \(url.path)")
throw GenerationError.failedToGenerateImage(url.path)
}
guard let data = tiffData.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
print("Failed to get data for image \(url.path)")
throw GenerationError.failedToGenerateImage(url.path)
}
try data.createFolderAndWrite(to: url)
}
#endif
}
private extension Int {
func multiply(by factor: Int) -> Int {
self * factor
}
}
private func getScaledSize(of source: NSSize, to desiredWidth: CGFloat) -> NSSize {
if source.width == desiredWidth {
return source
}
if source.width < desiredWidth {
// Keep existing image if image is too small already
return source
//print("Image \(destination.path) too small (wanted width \(desiredWidth), has only \(sourceWidth))")
}
let height = source.height * desiredWidth / source.width
return NSSize(width: desiredWidth, height: height)
}
private func scale(image: NSImage, to size: NSSize) -> NSImage {
guard image.size.width > size.width else {
return image
}
//resize image
return NSImage(size: size, flipped: false) { (resizedRect) -> Bool in
image.draw(in: resizedRect)
return true
}
}
private extension NSSize {
var ratio: CGFloat {
width / height
}
}

View File

@ -1,105 +0,0 @@
import Foundation
enum FileSystem {
fileprivate static var fm: FileManager { .default }
static func folders(in folder: URL) throws -> [URL] {
try fm.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.isDirectory }
}
/**
Copy a file to the destination, creating the containing folder if needed
*/
static func copy(_ source: URL, to destination: URL) throws {
try destination.ensureParentFolderExistence()
try source.copy(to: destination)
}
}
extension URL {
func ensureParentFolderExistence() throws {
try deletingLastPathComponent().ensureFolderExistence()
}
func ensureFolderExistence() throws {
if !exists {
print("Creating directory \(path)")
try wrap(.failedToWriteFile(path)) {
try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true)
}
}
}
var isDirectory: Bool {
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
}
var exists: Bool {
FileSystem.fm.fileExists(atPath: path)
}
/**
Delete the file at the url.
*/
func delete() throws {
try FileSystem.fm.removeItem(at: self)
}
func copy(to url: URL) throws {
try wrap(.failedToWriteFile(url.path)) {
if url.exists {
try url.delete()
}
try url.ensureParentFolderExistence()
try FileSystem.fm.copyItem(at: self, to: url)
}
}
}
extension Data {
func createFolderAndWrite(to url: URL) throws {
try url.ensureParentFolderExistence()
// if url.exists {
// print("Overwriting \(url.path)")
// }
try wrap(.failedToWriteFile(url.path)) {
try write(to: url)
}
}
}
extension String {
func createFolderAndWrite(to url: URL) throws {
try data(using: .utf8)!.createFolderAndWrite(to: url)
}
}
extension Encodable {
func writeJSON(to url: URL) throws {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
let content = try wrap(.failedToEncodeJSON(url.path)) {
try encoder.encode(self)
}
try content.createFolderAndWrite(to: url)
}
}
extension Decodable {
init(decodeFrom url: URL) throws {
let data = try wrap(.failedToOpenFile(url.path)) {
try Data(contentsOf: url)
}
self = try wrap({ .failedToDecodeJSON(file: url.path, error: $0.localizedDescription)}) {
try JSONDecoder().decode(Self.self, from: data)
}
}
}

View File

@ -0,0 +1,378 @@
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 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) -> String? {
let url = input.appendingPathComponent(path)
guard exists(url) else {
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 {
print("Failed to load image data \(path)")
return nil
}
guard let image = NSImage(data: data) else {
print("Failed to read image \(path)")
return nil
}
return (image, changed)
}
@discardableResult
func requireImage(source: String, destination: String, width: Int, desiredHeight: Int? = nil, createDoubleVersion: Bool = false) -> 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 image \(source) with size (\(width),\(desiredHeight ?? -1)",
source: "Image Processor")
return standardSize
}
guard let imageSize = loadImage(atPath: image.source)?.image.size else {
log.add(error: "Unreadable image \(source) with size (\(width),\(desiredHeight ?? -1)",
source: "Image Processor")
return standardSize
}
let scaledSize = imageSize.scaledDown(to: CGFloat(width))
guard let existing = imageTasks[destination] else {
//print("Image(\(image.width),\(image.desiredHeight ?? -1)) requested for \(destination)")
imageTasks[destination] = image
return scaledSize
}
guard existing.source == source else {
log.add(error: "Multiple sources (\(existing.source),\(source)) for image \(destination)",
source: "Image Processor")
return scaledSize
}
guard existing.hasSimilarRatio(as: image) else {
log.add(error: "Multiple ratios (\(existing.ratio!),\(image.ratio!)) for image \(destination)",
source: "Image Processor")
return scaledSize
}
if image.width > existing.width {
log.add(info: "Increasing size of image \(destination) from \(existing.width) to \(width)",
source: "Image Processor")
imageTasks[destination] = image
}
return scaledSize
}
#warning("Implement image functions")
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 image \(image.source)", source: "Image Processor")
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 MediaType.isProcessableImage(ext) else {
log.add(info: "Copying image \(image.source)", source: "Image Processor")
do {
let sourceUrl = input.appendingPathComponent(image.source)
try destinationUrl.ensureParentFolderExistence()
try sourceUrl.copy(to: destinationUrl)
} catch {
log.add(error: "Failed to copy image \(image.source) to \(destination)", source: "Image Processor")
}
return
}
guard let sourceImage = NSImage(data: sourceImageData) else {
print("Failed to read image \(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 {
print("[WARN] Image \(destination) scaled incorrectly (wanted width \(desiredWidth), is \(scaledSize.width))")
}
if abs(destinationSize.height - scaledImage.size.height) > 2 {
print("[WARN] Image \(destination) scaled incorrectly (wanted height \(destinationSize.height), is \(scaledSize.height))")
}
if let desiredHeight = desiredHeight {
let desiredRatio = desiredHeight / desiredWidth
let adjustedDesiredHeight = scaledSize.width * desiredRatio
if abs(adjustedDesiredHeight - scaledSize.height) > 5 {
log.add(warning: "Image \(image.source): Desired height \(adjustedDesiredHeight) (actually \(desiredHeight)), got \(scaledSize.height) after reduction", source: "Image Processor")
return
}
}
if scaledSize.width > desiredWidth {
print("[WARN] Image \(image.source) is too large (expected width \(desiredWidth), got \(scaledSize.width)")
}
let destinationExtension = destinationUrl.pathExtension.lowercased()
guard let type = MediaType.supportedImage(destinationExtension) else {
log.add(error: "No image type for \(destination) with extension \(destinationExtension)",
source: "Image Processor")
return
}
guard let tiff = scaledImage.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else {
log.add(error: "Failed to get data for image \(image.source)", source: "Image Processor")
return
}
guard let data = tiffData.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
log.add(error: "Failed to get data for image \(image.source)", source: "Image Processor")
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)
}
func copyRequiredFiles() {
var missingFiles = [String]()
for file in requiredFiles {
let sourceUrl = input.appendingPathComponent(file)
guard sourceUrl.exists else {
missingFiles.append(file)
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
}
let destinationUrl = output.appendingPathComponent(file)
write(data, to: destinationUrl)
}
}
// MARK: Writing files
@discardableResult
func write(_ 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 write(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

@ -0,0 +1,24 @@
import Foundation
struct ImageOutput: Hashable {
let source: String
let width: Int
let desiredHeight: Int?
var ratio: Float? {
guard let desiredHeight = desiredHeight else {
return nil
}
return Float(desiredHeight) / Float(width)
}
func hasSimilarRatio(as other: ImageOutput) -> Bool {
guard let other = other.ratio, let ratio = ratio else {
return true
}
return abs(other - ratio) < 0.1
}
}

View File

@ -0,0 +1,36 @@
import Foundation
import AppKit
private let supportedImageExtensions: [String : NSBitmapImageRep.FileType] = [
"jpg" : .jpeg,
"jpeg" : .jpeg,
"png" : .png,
]
private let supportedVideoExtensions: Set<String> = [
"mp4", "mov"
]
enum MediaType {
case image
case video
case file
init(fileExtension: String) {
if supportedImageExtensions[fileExtension] != nil {
self = .image
} else if supportedVideoExtensions.contains(fileExtension) {
self = .video
} else {
self = .file
}
}
static func isProcessableImage(_ fileExtension: String) -> Bool {
supportedImage(fileExtension) != nil
}
static func supportedImage(_ fileExtension: String) -> NSBitmapImageRep.FileType? {
supportedImageExtensions[fileExtension]
}
}

View File

@ -1,6 +1,6 @@
import Foundation
final class ErrorOutput {
final class ValidationLog {
private enum LogLevel: String {
case error = "ERROR"
@ -138,7 +138,7 @@ final class ErrorOutput {
guard let string = string else {
return nil
}
guard let date = ErrorOutput.metadataDate.date(from: string) else {
guard let date = ValidationLog.metadataDate.date(from: string) else {
add(warning: "Invalid date string '\(string)' for property '\(property)'", source: source)
return nil
}

View File

@ -1,51 +0,0 @@
import Foundation
enum GenerationError: Error {
case missingMarkerInTemplate(String)
case failedToLoadTemplate(String)
case failedToWriteFile(String)
case failedToOpenFile(String)
case failedToDecodeJSON(file: String, error: String)
case invalidLanguageSpecification(String)
case invalidDateInPageMetadata(String)
case failedToEncodeJSON(String)
case missingSectionMetadata(section: String, language: String)
case missingPageMetadata(page: String, language: String)
case missingPage(page: String, language: String)
case missingImage(String)
case conflictingImageSources(output: String, in1: String, in2: String)
case conflictingImageRatios(output: String, in1: String, in2: String)
case failedToGenerateImage(String)
case imageRatioMismatch(String)
}
func wrap<T>(_ error: GenerationError, execute: () throws -> T) rethrows -> T {
do {
return try execute()
} catch let underlyingError {
print(underlyingError)
throw error
}
}
func wrap<T>(_ error: (Error) -> GenerationError, execute: () throws -> T) rethrows -> T {
do {
return try execute()
} catch let underlyingError {
print(underlyingError)
throw error(underlyingError)
}
}
extension Optional {
func unwrap(or error: GenerationError) throws -> Wrapped {
switch self {
case .none:
throw error
case .some(let wrapped):
return wrapped
}
}
}

View File

@ -1,21 +1,5 @@
import Foundation
private struct AboveRootDummy: SiteElement {
var sortIndex: Int? { nil }
var sortDate: Date? { nil }
var path: String { "" }
let inputFolder: URL
func title(for language: String) -> String { "" }
func cornerText(for language: String) -> String? { nil }
var elements: [SiteElement] { [] }
}
struct IndexPageGenerator {
private let factory: LocalizedSiteTemplate
@ -25,41 +9,34 @@ struct IndexPageGenerator {
}
func generate(
site: Site,
site: Element,
language: String,
languageButton: String?,
sectionItemCount: Int,
to url: URL) throws {
to url: URL) {
let localized = site.localized(for: language)
var content = [PageTemplate.Key : String]()
content[.head] = try makeHead(site: site, language: language)
content[.topBar] = factory.topBar.generate(section: nil, languageButton: languageButton)
content[.head] = factory.pageHead.generate(page: site, language: language)
content[.topBar] = factory.topBar.generate(sectionUrl: nil, languageButton: languageButton)
content[.contentClass] = "overview"
content[.header] = makeHeader(localized: localized)
let sections = site.elements.compactMap { $0 as? Section }
content[.content] = try factory.overviewSection.generate(
sections: sections,
content[.content] = factory.overviewSection.generate(
sections: site.elements,
in: site,
language: language,
sectionItemCount: sectionItemCount)
content[.footer] = try site.customFooterContent()
try factory.page.generate(content, to: url)
content[.footer] = site.customFooterContent()
guard factory.page.generate(content, to: url) else {
return
}
log.add(info: "Generated \(url.lastPathComponent)", source: site.path)
}
private func makeHead(site: Site, language: String) throws -> String {
let localized = site.localized(for: language)
return try factory.pageHead.generate(page: PageHeadInfo(
author: site.metadata.author,
linkPreviewTitle: localized.linkPreviewTitle,
linkPreviewDescription: localized.linkPreviewDescription,
linkPreviewImage: site.linkPreviewImage(for: language),
customHeadContent: try site.customHeadContent()))
}
private func makeHeader(localized: Site.LocalizedMetadata) -> String {
private func makeHeader(localized: Element.LocalizedMetadata) -> String {
var content = [HeaderKey : String]()
content[.title] = localized.title
#warning("Add title suffix")
content[.subtitle] = localized.subtitle
content[.titleText] = localized.description
return factory.factory.centeredHeader.generate(content)

View File

@ -9,31 +9,18 @@ struct PageContentGenerator {
private let factory: TemplateFactory
private let files: FileProcessor
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
init(factory: TemplateFactory, files: FileProcessor) {
init(factory: TemplateFactory) {
self.factory = factory
self.files = files
}
func generate(page: Page, language: String, at url: URL) throws -> String {
var errorToThrow: Error? = nil
let content = try wrap(.missingPage(page: url.path, language: language)) {
try String(contentsOf: url)
}
func generate(page: Element, language: String, content: String) -> String {
var hasCodeContent = false
let imageModifier = Modifier(target: .images) { html, markdown in
do {
return try processMarkdownImage(markdown: markdown, html: html, page: page)
} catch {
errorToThrow = error
return ""
}
processMarkdownImage(markdown: markdown, html: html, page: page)
}
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
if markdown.starts(with: "```swift") {
@ -51,17 +38,13 @@ struct PageContentGenerator {
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier])
if hasCodeContent {
#warning("Automatically add hljs hightlighting if code samples are found")
#warning("Automatically add hljs highlighting if code samples are found")
}
let result = parser.html(from: content)
if let error = errorToThrow {
throw error
}
return result
return parser.html(from: content)
}
private func processMarkdownImage(markdown: Substring, html: String, page: Page) throws -> String {
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: ![option...](file)
@ -72,27 +55,27 @@ struct PageContentGenerator {
let alt = markdown.between("[", and: "]").nonEmpty
let fileExtension = file.lastComponentAfter(".").lowercased()
switch files.mediaType(forExtension: fileExtension) {
switch MediaType(fileExtension: fileExtension) {
case .image:
return try handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
return handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
case .video:
return try handleVideo(page: page, file: file, optionString: alt)
return handleVideo(page: page, file: file, optionString: alt)
case .file:
if fileExtension == "svg" {
return try handleSvg(page: page, file: file)
return handleSvg(page: page, file: file)
}
return try handleFile(page: page, file: file, fileExtension: fileExtension)
return handleFile(page: page, file: file, fileExtension: fileExtension)
}
}
private func handleImage(page: Page, file: String, rightTitle: String?, leftTitle: String?) throws -> String {
private func handleImage(page: Element, file: String, rightTitle: String?, leftTitle: String?) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
let size = try files.requireImage(source: imagePath, destination: imagePath, width: pageImageWidth)
let size = files.requireImage(source: imagePath, destination: imagePath, width: pageImageWidth)
let imagePath2x = imagePath.insert("@2x", beforeLast: ".")
let file2x = file.insert("@2x", beforeLast: ".")
try files.requireImage(source: imagePath, destination: imagePath2x, width: 2 * pageImageWidth)
files.requireImage(source: imagePath, destination: imagePath2x, width: 2 * pageImageWidth)
let content: [PageImageTemplate.Key : String] = [
.image: file,
@ -104,7 +87,7 @@ struct PageContentGenerator {
return factory.image.generate(content)
}
private func handleVideo(page: Page, file: String, optionString: String?) throws -> String {
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 {
@ -125,7 +108,7 @@ struct PageContentGenerator {
return factory.video.generate(sources: sources, options: options)
}
private func handleSvg(page: Page, file: String) throws -> String {
private func handleSvg(page: Element, file: String) -> String {
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
files.require(file: imagePath)
@ -136,7 +119,7 @@ struct PageContentGenerator {
"""
}
private func handleFile(page: Page, file: String, fileExtension: String) throws -> String {
private func handleFile(page: Element, file: String, fileExtension: String) -> String {
#warning("Handle other files in markdown")
print("[WARN] Unhandled file \(file) with extension \(fileExtension)")
return ""

View File

@ -4,65 +4,53 @@ struct OverviewPageGenerator {
private let factory: LocalizedSiteTemplate
let outputFolder: URL
init(factory: LocalizedSiteTemplate, files: FileProcessor) {
init(factory: LocalizedSiteTemplate) {
self.factory = factory
self.outputFolder = files.outputFolder
}
func generate(
section: Section,
section: Element,
language: String,
backText: String?) throws {
let url = outputFolder.appendingPathComponent(section.localizedPath(for: language))
backText: String?) {
let path = section.localizedPath(for: language)
let url = files.urlInOutputFolder(path)
let metadata = section.localized(for: language)
var content = [PageTemplate.Key : String]()
content[.head] = try makeHead(section: section, language: language)
content[.head] = factory.pageHead.generate(page: section, language: language)
let languageButton = section.nextLanguage(for: language)
content[.topBar] = factory.topBar.generate(
section: section.sectionId,
sectionUrl: section.sectionUrl(for: language),
languageButton: languageButton)
content[.contentClass] = "overview"
content[.header] = makeHeader(metadata: metadata, language: language, backText: backText)
content[.content] = try makeContent(section: section, language: language)
content[.footer] = try section.customFooterContent()
try factory.page.generate(content, to: url)
content[.content] = makeContent(section: section, language: language)
content[.footer] = section.customFooterContent()
guard factory.page.generate(content, to: url) else {
return
}
log.add(info: "Generated \(path)", source: section.path)
}
private func makeContent(section: Section, language: String) throws -> String {
private func makeContent(section: Element, language: String) -> String {
if section.hasNestingElements {
let sections = section.elements.compactMap { $0 as? Section }
return try factory.overviewSection.generate(
sections: sections,
return factory.overviewSection.generate(
sections: section.elements,
in: section,
language: language,
sectionItemCount: section.metadata.sectionOverviewItemCount)
sectionItemCount: section.overviewItemCount)
} else {
return try factory.overviewSection.generate(section: section, language: language)
return factory.overviewSection.generate(section: section, language: language)
}
}
private func makeHead(section: Section, language: String) throws -> String {
let localized = section.localized(for: language)
let image = section.linkPreviewImage(for: language)
let info = PageHeadInfo(
author: factory.author,
linkPreviewTitle: localized.title,
linkPreviewDescription: localized.linkPreviewDescription,
linkPreviewImage: image,
customHeadContent: try section.customHeadContent())
return try factory.pageHead.generate(page: info)
}
private func makeHeader(metadata: Section.LocalizedMetadata,
private func makeHeader(metadata: Element.LocalizedMetadata,
language: String,
backText: String?) -> String {
var content = [HeaderKey : String]()
content[.title] = metadata.title
#warning("Add title suffix")
content[.subtitle] = metadata.subtitle
content[.titleText] = metadata.description
content[.backLink] = backText.unwrapped { factory.makeBackLink(text: $0, language: language) }

View File

@ -6,19 +6,16 @@ struct OverviewSectionGenerator {
private let singleSectionsTemplate: OverviewSectionCleanTemplate
let files: FileProcessor
private let generator: ThumbnailListGenerator
init(factory: TemplateFactory, files: FileProcessor) {
init(factory: TemplateFactory) {
self.multipleSectionsTemplate = factory.overviewSection
self.singleSectionsTemplate = factory.overviewSectionClean
self.files = files
self.generator = ThumbnailListGenerator(factory: factory, files: files)
self.generator = ThumbnailListGenerator(factory: factory)
}
func generate(sections: [Section], in parent: SiteElement, language: String, sectionItemCount: Int) throws -> String {
try sections.map { section in
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)
@ -26,44 +23,31 @@ struct OverviewSectionGenerator {
var content = [OverviewSectionTemplate.Key : String]()
content[.url] = relativeUrl
content[.title] = metadata.title
content[.items] = try sectionContent(section: section, in: parent, language: language, shownItemCount: sectionItemCount)
content[.more] = metadata.moreLinkTitle
content[.items] = sectionContent(section: section, in: parent, language: language, shownItemCount: sectionItemCount)
content[.more] = metadata.moreLinkText
return multipleSectionsTemplate.generate(content)
}
.joined(separator: "\n")
}
func generate(section: Section, language: String) throws -> String {
func generate(section: Element, language: String) -> String {
var content = [OverviewSectionCleanTemplate.Key : String]()
content[.items] = try sectionContent(section: section, in: section, language: language, shownItemCount: nil)
content[.items] = sectionContent(section: section, in: section, language: language, shownItemCount: nil)
return singleSectionsTemplate.generate(content)
}
private func sectionContent(section: Section, in parent: SiteElement, language: String, shownItemCount: Int?) throws -> String {
let sectionItems: [SiteElement]
private func sectionContent(section: Element, in parent: Element, language: String, shownItemCount: Int?) -> String {
let sectionItems: [Element]
if let shownItemCount = shownItemCount {
sectionItems = Array(section.sortedItems.prefix(shownItemCount))
} else {
sectionItems = section.sortedItems
}
let items: [ThumbnailInfo] = sectionItems.map { item in
#warning("Check if page exists for the language")
let fullPageUrl = item.fullPageUrl(for: language)
let relativePageUrl = parent.relativePathToFileWithPath(fullPageUrl)
let fullThumbnailPath = item.thumbnailFilePath(for: language)
let relativeImageUrl = parent.relativePathToFileWithPath(fullThumbnailPath)
return ThumbnailInfo(
url: relativePageUrl,
imageFilePath: fullThumbnailPath,
imageHtmlUrl: relativeImageUrl,
title: item.title(for: language),
cornerText: item.cornerText(for: language))
}
return try generator.generateContent(
items: items,
style: section.metadata.thumbnailStyle)
return generator.generateContent(
items: sectionItems,
parent: parent,
language: language,
style: section.thumbnailStyle)
}
}

View File

@ -12,70 +12,67 @@ struct PageGenerator {
private let factory: LocalizedSiteTemplate
private let files: FileProcessor
init(factory: LocalizedSiteTemplate, files: FileProcessor) {
init(factory: LocalizedSiteTemplate) {
self.factory = factory
self.files = files
}
func generate(page: Page, language: String, backText: String, nextPage: NavigationLink?, previousPage: NavigationLink?) throws {
func generate(page: Element, language: String, backText: String, nextPage: NavigationLink?, previousPage: NavigationLink?) {
guard !page.isExternalPage else {
return
}
guard !page.metadata.isDraft else {
guard page.state != .draft else {
return
}
let path = page.fullPageUrl(for: language)
let inputContentUrl = page.inputFolder.appendingPathComponent("\(language).md")
let inputContentPath = page.path + "/\(language).md"
#warning("Make prev and next navigation relative")
let metadata = page.localized(for: language)
let nextLanguage = page.nextLanguage(for: language)
var content = [PageTemplate.Key : String]()
content[.head] = try makeHead(page: page, language: language)
content[.topBar] = factory.topBar.generate(section: page.sectionId, languageButton: nextLanguage)
content[.head] = factory.pageHead.generate(page: page, language: language)
let sectionUrl = page.sectionUrl(for: language)
content[.topBar] = factory.topBar.generate(sectionUrl: sectionUrl, languageButton: nextLanguage)
content[.contentClass] = "content"
if !page.metadata.useCustomHeader {
content[.header] = makeHeader(page: page.metadata, metadata: metadata, language: language, backText: backText)
if !page.useCustomHeader {
content[.header] = makeHeader(page: page, metadata: metadata, language: language, backText: backText)
}
content[.content] = try makeContent(page: page, language: language, url: inputContentUrl)
let pageContent = makeContent(page: page, language: language, path: inputContentPath)
content[.content] = pageContent ?? factory.placeholder
content[.previousPageLinkText] = previousPage.unwrapped { factory.makePrevText($0.text) }
content[.previousPageUrl] = previousPage?.link
content[.nextPageLinkText] = nextPage.unwrapped { factory.makeNextText($0.text) }
content[.nextPageUrl] = nextPage?.link
content[.footer] = try page.customFooterContent()
content[.footer] = page.customFooterContent()
let url = files.outputFolder.appendingPathComponent(path)
try factory.page.generate(content, to: url)
}
private func makeContent(page: Page, language: String, url: URL) throws -> String {
guard url.exists else {
print("Generated empty page \(page.path)")
return factory.placeholder
let url = files.urlInOutputFolder(path)
guard factory.page.generate(content, to: url) else {
return
}
print("Generated page \(page.path)")
return try PageContentGenerator(factory: factory.factory, files: files)
.generate(page: page, language: language, at: url)
log.add(info: "Generated \(pageContent == nil ? "empty page " : "")\(path)", source: page.path)
}
private func makeHead(page: Page, language: String) throws -> String {
let metadata = page.localized(for: language)
let info = PageHeadInfo(
author: page.metadata.author ?? factory.author,
linkPreviewTitle: metadata.linkPreviewTitle,
linkPreviewDescription: metadata.linkPreviewDescription,
linkPreviewImage: page.linkPreviewImage(for: language),
customHeadContent: try page.customHeadContent())
return try factory.pageHead.generate(page: info)
private func makeContent(page: Element, language: String, path: String) -> String? {
guard let content = files.contentOfOptionalFile(atPath: path, source: page.path) else {
return nil
}
return PageContentGenerator(factory: factory.factory)
.generate(page: page, language: language, content: content)
}
private func makeHeader(page: Page.Metadata, metadata: Page.LocalizedMetadata, language: String, backText: String) -> String {
private func makeHeader(page: Element, metadata: Element.LocalizedMetadata, language: String, backText: String) -> String {
var content = [HeaderKey : String]()
content[.backLink] = factory.makeBackLink(text: backText, language: language)
content[.title] = metadata.title
if let suffix = metadata.titleSuffix {
content[.title] = make(title: metadata.title, suffix: suffix)
} else {
content[.title] = metadata.title
}
content[.subtitle] = metadata.subtitle
content[.date] = factory.makeDateString(start: page.date, end: page.endDate)
return factory.factory.leftHeader.generate(content)
}
private func make(title: String, suffix: String) -> String {
"\(title)<span class=\"suffix\">\(suffix)</span>"
}
}

View File

@ -1,60 +1,36 @@
import Foundation
protocol PageHeadInfoProvider {
var author: String { get }
var linkPreviewTitle: String { get }
var linkPreviewDescription: String { get }
var linkPreviewImage: String? { get }
var customHeadContent: String? { get }
}
struct PageHeadInfo: PageHeadInfoProvider {
let author: String
let linkPreviewTitle: String
let linkPreviewDescription: String
let linkPreviewImage: String?
let customHeadContent: String?
}
struct PageHeadGenerator {
static let linkPreviewDesiredImageWidth = 1600
let template: PageHeadTemplate
let files: FileProcessor
init(factory: TemplateFactory, files: FileProcessor) {
init(factory: TemplateFactory) {
self.template = factory.pageHead
self.files = files
}
func generate(page: PageHeadInfoProvider) throws -> String {
func generate(page: Element, language: String) -> String {
let metadata = page.localized(for: language)
var content = [PageHeadTemplate.Key : String]()
content[.author] = page.author
content[.title] = page.linkPreviewTitle
content[.description] = page.linkPreviewDescription
if let image = page.linkPreviewImage {
content[.title] = metadata.linkPreviewTitle
content[.description] = metadata.linkPreviewDescription
if let image = page.linkPreviewImage(for: language) {
// 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 linkPreviewImagePath = image.insert("-link", beforeLast: ".")
try files.requireImage(
source: image,
destination: linkPreviewImagePath,
width: Site.linkPreviewDesiredImageWidth)
#warning("Make link preview image path absolute")
content[.image] = "<meta property=\"og:image\" content=\"\(linkPreviewImagePath)\" />"
let linkPreviewImageName = image.insert("-link", beforeLast: ".")
let sourceImagePath = page.pathRelativeToRootForContainedInputFile(image)
let destinationImagePath = page.pathRelativeToRootForContainedInputFile(linkPreviewImageName)
files.requireImage(
source: sourceImagePath,
destination: destinationImagePath,
width: PageHeadGenerator.linkPreviewDesiredImageWidth)
content[.image] = "<meta property=\"og:image\" content=\"\(linkPreviewImageName)\" />"
}
content[.customPageContent] = page.customHeadContent
content[.customPageContent] = page.customHeadContent()
return template.generate(content)
}

View File

@ -2,60 +2,45 @@ import Foundation
struct SiteGenerator {
let site: Site
let templates: TemplateFactory
private let files: FileProcessor
private var outputFolder: URL {
files.outputFolder
}
init(site: Site, files: FileProcessor) throws {
self.site = site
let templatesFolder = site.inputFolder.appendingPathComponent("templates")
init() throws {
let templatesFolder = files.urlInContentFolder("templates")
self.templates = try TemplateFactory(templateFolder: templatesFolder)
self.files = files
}
func generate() throws {
try site.metadata.languages.forEach { metadata in
let language = metadata.languageIdentifier
func generate(site: Element) throws {
try site.languages.forEach { metadata in
let language = metadata.language
let template = try LocalizedSiteTemplate(
factory: templates,
language: language,
site: site,
files: files)
site: site)
// Generate sections
let overviewGenerator = OverviewPageGenerator(factory: template, files: files)
let pageGenerator = PageGenerator(factory: template, files: files)
let backLinkText = try site.backLinkText(for: language)
var elementsToProcess: [(element: SiteElement, backText: String?)] = site.elements.map { ($0, backLinkText) }
while let (element, backText) = elementsToProcess.popLast() {
if let section = element as? Section {
try overviewGenerator.generate(
section: section,
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)
let backLinkText = element.backLinkText(for: language)
if !element.elements.isEmpty {
overviewGenerator.generate(
section: element,
language: language,
backText: backText)
let elementBackText = try element.backLinkText(for: language)
let nestedElements = section.elements.map { ($0, elementBackText) }
elementsToProcess.append(contentsOf: nestedElements)
}
if let page = element as? Page {
backText: backLinkText)
} else {
#warning("Determine previous and next pages")
try pageGenerator.generate(
page: page,
pageGenerator.generate(
page: element,
language: language,
backText: backText ?? metadata.defaultBackLinkText,
backText: backLinkText,
nextPage: nil,
previousPage: nil)
for file in page.metadata.requiredFiles {
let relativePath = page.path + "/" + file
files.require(file: relativePath)
}
}
}
@ -63,9 +48,9 @@ struct SiteGenerator {
// Generate front page
let relativeUrl = site.localizedPath(for: language)
let indexPageUrl = outputFolder.appendingPathComponent(relativeUrl)
let indexPageUrl = files.urlInOutputFolder(relativeUrl)
let button = site.nextLanguage(for: language)
try generator.generate(
generator.generate(
site: site,
language: language,
languageButton: button,

View File

@ -1,14 +0,0 @@
import Foundation
struct ThumbnailInfo {
let url: String?
let imageFilePath: String
let imageHtmlUrl: String
let title: String
let cornerText: String?
}

View File

@ -4,35 +4,37 @@ struct ThumbnailListGenerator {
private let factory: TemplateFactory
let files: FileProcessor
init(factory: TemplateFactory, files: FileProcessor) {
init(factory: TemplateFactory) {
self.factory = factory
self.files = files
}
func generateContent(items: [ThumbnailInfo], style: ThumbnailStyle) throws -> String {
try items.map { try itemContent($0, style: style) }
func generateContent(items: [Element], parent: Element, language: String, style: ThumbnailStyle) -> String {
items.map { itemContent($0, parent: parent, language: language, style: style) }
.joined(separator: "\n")
}
private func itemContent(_ thumbnail: ThumbnailInfo, style: ThumbnailStyle) throws -> String {
private func itemContent(_ item: Element, parent: Element, language: String, style: ThumbnailStyle) -> String {
let fullPageUrl = item.fullPageUrl(for: language)
let relativePageUrl = parent.relativePathToFileWithPath(fullPageUrl)
let fullThumbnailPath = item.thumbnailFilePath(for: language)
let relativeImageUrl = parent.relativePathToFileWithPath(fullThumbnailPath)
var content = [ThumbnailKey : String]()
content[.url] = thumbnail.url.unwrapped { "href=\"\($0)\"" }
content[.image] = thumbnail.imageHtmlUrl
content[.title] = thumbnail.title
content[.image2x] = thumbnail.imageHtmlUrl.insert("@2x", beforeLast: ".")
content[.corner] = thumbnail.cornerText.unwrapped {
content[.url] = "href=\"\(relativePageUrl)\""
content[.image] = relativeImageUrl
content[.title] = item.title(for: language)
#warning("Generate thumbnail suffix")
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
content[.corner] = item.cornerText(for: language).unwrapped {
factory.largeThumbnail.makeCorner(text: $0)
}
try files.requireImage(
source: thumbnail.imageFilePath,
destination: thumbnail.imageFilePath,
files.requireImage(
source: fullThumbnailPath,
destination: fullThumbnailPath,
width: style.width,
desiredHeight: style.height,
createDoubleVersion: true)
return try factory.thumbnail(style: style).generate(content, shouldIndent: false)
return factory.thumbnail(style: style).generate(content, shouldIndent: false)
}
}

View File

@ -1,14 +0,0 @@
import Foundation
final class Context {
let validation: ErrorOutput
let fileSystem: FileAccess
init(inputFolder: URL, outputFolder: URL) {
let validation = ErrorOutput()
self.validation = validation
self.fileSystem = FileAccess(in: inputFolder, errorOutput: validation)
}
}

View File

@ -1,238 +0,0 @@
import Foundation
struct Element {
static let overviewItemCountDefault = 6
/**
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 url where the site will be deployed.
This value is required to build absolute links for link previews.
- Note: Only the root level value is used.
- Note: The path does not need to contain a trailing slash.
*/
let deployedBaseUrl: 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
/**
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.
- Parameter context: The context to create the element (validation, file access, etc.)
*/
init?(atRoot folder: URL, with context: Context) throws {
let validation = context.validation
self.inputFolder = folder
self.path = ""
let source = GenericMetadata.metadataFileName
guard let metadata = try GenericMetadata(source: source, with: context) else {
return nil
}
self.author = validation.required(metadata.author, name: "author", source: source) ?? "author"
self.topBarTitle = validation
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
self.deployedBaseUrl = validation
.required(metadata.deployedBaseUrl, name: "deployedBaseUrl", source: source) ?? "https://example.com"
self.date = validation.unused(metadata.date, "date", source: source)
self.endDate = validation.unused(metadata.endDate, "endDate", source: source)
self.state = validation.state(metadata.state, source: source)
self.sortIndex = validation.unused(metadata.sortIndex, "sortIndex", source: source)
self.externalFiles = metadata.externalFiles ?? []
self.requiredFiles = metadata.requiredFiles ?? []
self.thumbnailStyle = validation.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large
self.useManualSorting = validation.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
self.languages = validation.required(metadata.languages, name: "languages", source: source)?
.compactMap { language in
.init(atRoot: folder, data: language, with: context)
} ?? []
try self.readElements(in: folder, source: nil, with: context)
}
mutating func readElements(in folder: URL, source: String?, with context: Context) throws {
let subFolders: [URL]
do {
subFolders = try FileSystem.folders(in: folder)
} catch {
context.validation.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, with: context, path: s)
}
}
init?(parent: Element, folder: URL, with: Context, path: String) throws {
let validation = context.validation
self.inputFolder = folder
self.path = path
let source = path + "/" + GenericMetadata.metadataFileName
guard let metadata = try GenericMetadata(source: source, with: context) else {
return nil
}
self.author = metadata.author ?? parent.author
self.topBarTitle = validation
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
self.deployedBaseUrl = validation
.unused(metadata.deployedBaseUrl, "deployedBaseUrl", source: source) ?? parent.deployedBaseUrl
let date = validation.date(from: metadata.date, property: "date", source: source).ifNil {
if !parent.useManualSorting {
validation.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source)
}
}
self.date = date
self.endDate = validation.date(from: metadata.endDate, property: "endDate", source: source).ifNotNil {
if date == nil {
validation.add(warning: "Set 'endDate', but no 'date'", source: source)
}
}
self.state = validation.state(metadata.state, source: source)
self.sortIndex = metadata.sortIndex.ifNil {
if parent.useManualSorting {
validation.add(error: "No 'sortIndex', but parent defines 'useManualSorting' = true", source: source)
}
}
// TODO: Propagate external files from the parent if subpath matches?
self.externalFiles = metadata.externalFiles ?? []
self.requiredFiles = metadata.requiredFiles ?? []
self.thumbnailStyle = validation.thumbnailStyle(metadata.thumbnailStyle, source: source)
self.useManualSorting = metadata.useManualSorting ?? false
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
self.languages = parent.languages.compactMap { parentData in
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
validation.add(info: "Language '\(parentData.language)' not found", source: source)
return nil
}
return .init(folder: folder, data: data, source: source, parent: parentData, with: context)
}
// Check that each 'language' tag is present, and that all languages appear in the parent
validation.required(metadata.languages, name: "languages", source: source)?
.compactMap { validation.required($0.language, name: "language", source: source) }
.filter { language in
!parent.languages.contains { $0.language == language }
}
.forEach {
validation.add(warning: "Language '\($0)' not found in parent, so not generated", source: source)
}
try self.readElements(in: folder, source: path, with: context)
}
}
// MARK: Debug
extension Element {
func printTree(indentation: String = "") {
print(indentation + "/" + path)
elements.forEach { $0.printTree(indentation: indentation + " ") }
}
}

View File

@ -1,173 +0,0 @@
import Foundation
enum FileAccessError: Error {
case failedToReadFile(String, Error)
}
final class FileAccess {
static let accessTimesFileName = "access.json"
let errorOutput: ErrorOutput
let sourceFolder: URL
private let source = "FileAccess"
private var modificationTimeCacheFile: URL {
sourceFolder.appendingPathComponent(FileAccess.accessTimesFileName)
}
/**
The time stamps of last modified times for all accessed source files.
The key is the relative path to the file from the source
*/
private var sourceLastModifiedTimes: [String : Date] = [:]
private var changedFiles: Set<String> = []
private var accessedFiles: Set<String> = []
init(in root: URL, errorOutput: ErrorOutput) {
self.sourceFolder = root
self.errorOutput = errorOutput
loadSavedModificationTimes()
}
private func loadSavedModificationTimes() {
let url = modificationTimeCacheFile
guard url.exists else {
errorOutput.add(info: "No file modification times loaded, regarding all content as new", source: source)
return
}
let data: Data
do {
data = try Data(contentsOf: url)
} catch {
errorOutput.add(
warning: "File modification times data not read, regarding all content as new",
source: source,
error: error)
return
}
do {
self.sourceLastModifiedTimes = try JSONDecoder().decode(from: data)
} catch {
errorOutput.add(
warning: "File modification times not decoded, regarding all content as new",
source: source,
error: error)
try? url.delete()
return
}
}
private func didAccess(inputPath: String, modified lastModified: Date, source: String) {
accessedFiles.insert(inputPath)
guard let previousDate = sourceLastModifiedTimes[inputPath] else {
// File not processed before, so mark as changed
changedFiles.insert(inputPath)
return
}
guard lastModified > previousDate else {
// File is unchanged
return
}
changedFiles.insert(inputPath)
}
private func lastModifiedTime(of url: URL) -> Date? {
guard url.exists else {
return nil
}
do {
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
guard let date = attributes[.modificationDate] as? Date else {
errorOutput.add(warning: "Failed to read modification time of \(url.path)", source: source)
return nil
}
return date
} catch {
errorOutput.add(warning: "Failed to read file attributes of \(url.path)", source: source)
return nil
}
}
func loadStringContent(inputPath: String) throws -> String? {
try load(inputPath: inputPath, String.init)
}
func loadDataContent(inputPath: String) throws -> Data? {
try load(inputPath: inputPath) { try Data(contentsOf: $0) }
}
private func load<T>(inputPath: String, _ closure: (URL) throws -> T) rethrows -> T? {
let url = sourceFolder.appendingPathComponent(inputPath)
guard let modifiedDate = lastModifiedTime(of: url) else {
sourceLastModifiedTimes[inputPath] = nil
return nil
}
do {
let content = try closure(url)
didAccess(inputPath: inputPath, modified: modifiedDate, source: source)
return content
} catch {
throw FileAccessError.failedToReadFile(inputPath, error)
}
}
func didGenerateAllFiles() {
for file in changedFiles {
let url = sourceFolder.appendingPathComponent(file)
guard let date = lastModifiedTime(of: url) else {
continue
}
sourceLastModifiedTimes[file] = date
}
do {
let data = try JSONEncoder().encode(sourceLastModifiedTimes)
try data.write(to: modificationTimeCacheFile)
} catch {
errorOutput.add(warning: "Failed to save modification times", source: source, error: error)
}
}
func printChangedFilesOverview() {
let count = changedFiles.count
guard count > 0 else {
print("No files modified")
return
}
print("\(count) files modified:")
changedFiles.prefix(10).forEach { print(" " + $0) }
if count > 10 {
print(" ...")
}
}
func printAccessedFilesOverview() {
let count = accessedFiles.count
guard count > 0 else {
print("No files accessed")
return
}
print("\(count) files accessed:")
accessedFiles.prefix(10).forEach { print(" " + $0) }
if count > 10 {
print(" ...")
}
}
func printAllTouchedFiles() {
print("\(accessedFiles.count) files accessed:")
accessedFiles.sorted().forEach { file in
if changedFiles.contains(file) {
print(" \(file) (changed)")
} else {
print(" " + file)
}
}
}
}

View File

@ -2,7 +2,7 @@ import Foundation
protocol ThumbnailTemplate {
func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) throws -> String
func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) -> String
}
enum ThumbnailKey: String, CaseIterable {

View File

@ -31,27 +31,14 @@ struct LocalizedSiteTemplate {
topBar.language
}
// MARK: Thumbnails
func thumbnail(style: ThumbnailStyle) -> ThumbnailTemplate {
switch style {
case .large:
return factory.largeThumbnail
case .square:
return factory.squareThumbnail
case .small:
return factory.smallThumbnail
}
}
// MARK: Pages
var page: PageTemplate {
factory.page
}
init(factory: TemplateFactory, language: String, site: Site, files: FileProcessor) throws {
self.author = site.metadata.author
init(factory: TemplateFactory, language: String, site: Element) throws {
self.author = site.author
self.factory = factory
let df = DateFormatter()
@ -70,28 +57,23 @@ struct LocalizedSiteTemplate {
df3.locale = Locale(identifier: language)
self.day = df3
let metadata = site.localized(for: language)
let sections = site.elements.map {
PrefilledTopBarTemplate.SectionInfo(
id: $0.sectionId,
name: $0.title(for: language),
url: "\($0.path)/\(language).html")
}
let metadata = site.localized(for: language)
let title = site.metadata.topBarTitle ?? metadata.linkPreviewTitle
self.topBar = try .init(
template: factory.topBar,
language: language,
sections: sections,
topBarWebsiteTitle: title)
topBarWebsiteTitle: site.topBarTitle)
self.pageHead = PageHeadGenerator(
factory: factory,
files: files)
factory: factory)
self.overviewSection = OverviewSectionGenerator(
factory: factory,
files: files)
factory: factory)
self.placeholder = factory.placeholder.generate([
.title: metadata.placeholderTitle,
@ -108,8 +90,6 @@ struct LocalizedSiteTemplate {
return backNavigation.generate(content)
}
#warning("Move HTML code to single location")
func makePrevText(_ text: String) -> String {
"<span class=\"icon-back\"></span>\(text)"
}
@ -118,7 +98,10 @@ struct LocalizedSiteTemplate {
"\(text)<span class=\"icon-next\"></span>"
}
func makeDateString(start: Date, end: Date?) -> String {
func makeDateString(start: Date?, end: Date?) -> String {
guard let start = start else {
return ""
}
guard let end = end else {
return fullDateFormatter.string(from: start)
}

View File

@ -17,19 +17,19 @@ struct PrefilledTopBarTemplate {
self.topBarWebsiteTitle = topBarWebsiteTitle
}
func generate(section: String?, languageButton: String?) -> String {
func generate(sectionUrl: String?, languageButton: String?) -> String {
var content = [TopBarTemplate.Key : String]()
content[.title] = topBarWebsiteTitle
content[.titleLink] = topBarWebsiteTitle(language: language)
content[.elements] = elements(activeSection: section)
content[.elements] = elements(activeSectionUrl: sectionUrl)
content[.languageButton] = languageButton.unwrapped(topBarLanguageButton) ?? ""
return topBar.generate(content)
}
private func elements(activeSection: String?) -> String {
private func elements(activeSectionUrl: String?) -> String {
sections
.map {
topBarNavigationLink(url: $0.url, text: $0.name, isActive: activeSection == $0.id)
topBarNavigationLink(url: $0.url, text: $0.name, isActive: activeSectionUrl == $0.url)
}
.joined(separator: "\n")
}
@ -49,8 +49,6 @@ struct PrefilledTopBarTemplate {
struct SectionInfo {
let id: String
let name: String
let url: String

View File

@ -20,17 +20,13 @@ extension Template {
}
init(from url: URL) throws {
let raw = try wrap(.failedToLoadTemplate(url.lastPathComponent)) {
try String(contentsOf: url)
}
let raw = try String(contentsOf: url)
self.init(raw: raw)
}
func generate(_ content: [Key : String], to url: URL) throws {
func generate(_ content: [Key : String], to url: URL) -> Bool {
let content = generate(content)
try wrap(.failedToWriteFile(url.path)) {
try content.createFolderAndWrite(to: url)
}
return files.write(content, to: url)
}
func generate(_ content: [Key : String], shouldIndent: Bool = false) -> String {

View File

@ -1,13 +1,14 @@
import Foundation
let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace")
let outputDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace/Site")
private let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace")
private let outputDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace/Site")
let context = Context(inputFolder: contentDirectory, outputFolder: outputDirectory)
let log = ValidationLog()
let files = FileSystem(in: contentDirectory, to: outputDirectory)
let siteData: Element
private let siteData: Element
do {
guard let element = try Element(atRoot: contentDirectory, with: context) else {
guard let element = try Element(atRoot: contentDirectory) else {
exit(0)
}
siteData = element
@ -17,26 +18,13 @@ do {
}
siteData.printTree()
context.fileSystem.printAllTouchedFiles()
context.fileSystem.didGenerateAllFiles()
exit(0)
let files = FileProcessor(
inputFolder: contentDirectory, outputFolder: outputDirectory)
// 1: Load all site content
guard let site = try Site(folder: contentDirectory) else {
exit(0)
}
// site.printContents()
let siteGenerator = try SiteGenerator(site: site, files: files)
try siteGenerator.generate()
private let siteGenerator = try SiteGenerator()
try siteGenerator.generate(site: siteData)
print("Pages generated")
try files.createImages()
files.createImages()
print("Images generated")
try files.copyRequiredFiles()
files.copyRequiredFiles()
print("Required files copied")
#warning("Check that all metadata for each language is present")
files.writeHashes()