diff --git a/WebsiteGenerator.xcodeproj/project.pbxproj b/WebsiteGenerator.xcodeproj/project.pbxproj index 057eeaa..b297af7 100644 --- a/WebsiteGenerator.xcodeproj/project.pbxproj +++ b/WebsiteGenerator.xcodeproj/project.pbxproj @@ -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 = ""; }; - E22E8769289D84FD00E51191 /* Section+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+Metadata.swift"; sourceTree = ""; }; E22E876B289D855D00E51191 /* ThumbnailStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailStyle.swift; sourceTree = ""; }; - E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+LocalizedMetadata.swift"; sourceTree = ""; }; E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexPageGenerator.swift; sourceTree = ""; }; - E22E8777289DA0E100E51191 /* GenerationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationError.swift; sourceTree = ""; }; - E22E8779289DA9F900E51191 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = ""; }; E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionGenerator.swift; sourceTree = ""; }; - E22E877E289DC11F00E51191 /* Site+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+Metadata.swift"; sourceTree = ""; }; - E22E8781289DCCB600E51191 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; }; - E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+LocalizedMetadata.swift"; sourceTree = ""; }; - E22E8786289DDF4C00E51191 /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = ""; }; - E22E8788289DDF5700E51191 /* Page+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Metadata.swift"; sourceTree = ""; }; - E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+LocalizedMetadata.swift"; sourceTree = ""; }; - E22E8794289E81D700E51191 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = ""; }; - E22E8797289EA42C00E51191 /* FileProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProcessor.swift; sourceTree = ""; }; + E22E8794289E81D700E51191 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = ""; }; E22E879A289EE02F00E51191 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageGenerator.swift; sourceTree = ""; }; E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailListGenerator.swift; sourceTree = ""; }; @@ -106,11 +82,12 @@ E22E87AB289F1D3700E51191 /* Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template.swift; sourceTree = ""; }; E22E87AD289F1E0000E51191 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefilledTopBarTemplate.swift; sourceTree = ""; }; - E22E87B1289F296700E51191 /* ThumbnailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailInfo.swift; sourceTree = ""; }; - E22E87B5289FF67B00E51191 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; - E253C86828AFD86E0076B6D0 /* FileAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccess.swift; sourceTree = ""; }; - E253C86A28AFE0980076B6D0 /* Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = ""; }; - E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadataProvider.swift; sourceTree = ""; }; + E253C87628B767D50076B6D0 /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = ""; }; + E253C87928B810090076B6D0 /* ImageOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageOutput.swift; sourceTree = ""; }; + E253C87B28B8BFB80076B6D0 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = ""; }; + E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = ""; }; + E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+Extensions.swift"; sourceTree = ""; }; + E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extensions.swift"; sourceTree = ""; }; E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderTemplate.swift; sourceTree = ""; }; E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = ""; }; E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadTemplate.swift; sourceTree = ""; }; @@ -120,10 +97,7 @@ E2C5A5E228A037F900102A25 /* PageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTemplate.swift; sourceTree = ""; }; E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationTemplate.swift; sourceTree = ""; }; E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSiteTemplate.swift; sourceTree = ""; }; - E2C5A5EB28A055E900102A25 /* SiteElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteElement.swift; sourceTree = ""; }; - E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageContainer.swift; sourceTree = ""; }; E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionCleanTemplate.swift; sourceTree = ""; }; - E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadata.swift; sourceTree = ""; }; E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = ""; }; E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = ""; }; E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = ""; }; @@ -135,7 +109,7 @@ E2F8FA3328AD6F3400632026 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = ""; }; E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Element+LocalizedMetadata.swift"; sourceTree = ""; }; E2F8FA3728AE27A500632026 /* ContentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentError.swift; sourceTree = ""; }; - E2F8FA3928AE313A00632026 /* ErrorOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorOutput.swift; sourceTree = ""; }; + E2F8FA3928AE313A00632026 /* ValidationLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationLog.swift; sourceTree = ""; }; E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decodable+Extensions.swift"; sourceTree = ""; }; /* 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 = ""; @@ -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 = ""; }; - 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 = ""; - }; 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 = ""; }; + 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 = ""; + }; E2C5A5D328A0222B00102A25 /* Templates */ = { isa = PBXGroup; children = ( @@ -279,20 +243,17 @@ path = Filled; sourceTree = ""; }; - 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 = ""; }; /* 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; }; diff --git a/WebsiteGenerator.xcodeproj/xcshareddata/xcschemes/WebsiteGenerator.xcscheme b/WebsiteGenerator.xcodeproj/xcshareddata/xcschemes/WebsiteGenerator.xcscheme new file mode 100644 index 0000000..dbac244 --- /dev/null +++ b/WebsiteGenerator.xcodeproj/xcshareddata/xcschemes/WebsiteGenerator.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WebsiteGenerator.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist b/WebsiteGenerator.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist index 06c0834..a817c96 100644 --- a/WebsiteGenerator.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/WebsiteGenerator.xcodeproj/xcuserdata/ch.xcuserdatad/xcschemes/xcschememanagement.plist @@ -10,5 +10,13 @@ 0 + SuppressBuildableAutocreation + + E22E875E289D84C300E51191 + + primary + + + diff --git a/WebsiteGenerator/Generic/Element+LocalizedMetadata.swift b/WebsiteGenerator/Content/Element+LocalizedMetadata.swift similarity index 85% rename from WebsiteGenerator/Generic/Element+LocalizedMetadata.swift rename to WebsiteGenerator/Content/Element+LocalizedMetadata.swift index a1b9455..5cbd2dc 100644 --- a/WebsiteGenerator/Generic/Element+LocalizedMetadata.swift +++ b/WebsiteGenerator/Content/Element+LocalizedMetadata.swift @@ -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 diff --git a/WebsiteGenerator/Content/Element.swift b/WebsiteGenerator/Content/Element.swift new file mode 100644 index 0000000..aac8c63 --- /dev/null +++ b/WebsiteGenerator/Content/Element.swift @@ -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 + + /** + 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 + + /** + 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.. 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 + " ") } + } +} diff --git a/WebsiteGenerator/Generic/GenericMetadata+Localized.swift b/WebsiteGenerator/Content/GenericMetadata+Localized.swift similarity index 100% rename from WebsiteGenerator/Generic/GenericMetadata+Localized.swift rename to WebsiteGenerator/Content/GenericMetadata+Localized.swift diff --git a/WebsiteGenerator/Generic/GenericMetadata.swift b/WebsiteGenerator/Content/GenericMetadata.swift similarity index 84% rename from WebsiteGenerator/Generic/GenericMetadata.swift rename to WebsiteGenerator/Content/GenericMetadata.swift index a2a6b31..7defff3 100644 --- a/WebsiteGenerator/Generic/GenericMetadata.swift +++ b/WebsiteGenerator/Content/GenericMetadata.swift @@ -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,15 +145,16 @@ 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 } - + let decoder = JSONDecoder() let knownKeys = GenericMetadata.knownKeys @@ -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 } } diff --git a/WebsiteGenerator/Content/LanguageContainer.swift b/WebsiteGenerator/Content/LanguageContainer.swift deleted file mode 100644 index 0ec161a..0000000 --- a/WebsiteGenerator/Content/LanguageContainer.swift +++ /dev/null @@ -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 `

` 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.. 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 `

` 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)" - } -} diff --git a/WebsiteGenerator/Content/LinkPreviewMetadata.swift b/WebsiteGenerator/Content/LinkPreviewMetadata.swift deleted file mode 100644 index 1e32e7c..0000000 --- a/WebsiteGenerator/Content/LinkPreviewMetadata.swift +++ /dev/null @@ -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") - } -} diff --git a/WebsiteGenerator/Content/LinkPreviewMetadataProvider.swift b/WebsiteGenerator/Content/LinkPreviewMetadataProvider.swift deleted file mode 100644 index 26711d8..0000000 --- a/WebsiteGenerator/Content/LinkPreviewMetadataProvider.swift +++ /dev/null @@ -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 - } -} diff --git a/WebsiteGenerator/Content/Metadata.swift b/WebsiteGenerator/Content/Metadata.swift deleted file mode 100644 index e5e4399..0000000 --- a/WebsiteGenerator/Content/Metadata.swift +++ /dev/null @@ -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) - } -} diff --git a/WebsiteGenerator/Content/Page+LocalizedMetadata.swift b/WebsiteGenerator/Content/Page+LocalizedMetadata.swift deleted file mode 100644 index 4ef9505..0000000 --- a/WebsiteGenerator/Content/Page+LocalizedMetadata.swift +++ /dev/null @@ -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 } -} diff --git a/WebsiteGenerator/Content/Page+Metadata.swift b/WebsiteGenerator/Content/Page+Metadata.swift deleted file mode 100644 index 073e821..0000000 --- a/WebsiteGenerator/Content/Page+Metadata.swift +++ /dev/null @@ -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 - } -} diff --git a/WebsiteGenerator/Content/Page.swift b/WebsiteGenerator/Content/Page.swift deleted file mode 100644 index 4879c30..0000000 --- a/WebsiteGenerator/Content/Page.swift +++ /dev/null @@ -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 - } - - -} diff --git a/WebsiteGenerator/Generic/PageState.swift b/WebsiteGenerator/Content/PageState.swift similarity index 100% rename from WebsiteGenerator/Generic/PageState.swift rename to WebsiteGenerator/Content/PageState.swift diff --git a/WebsiteGenerator/Content/Section+LocalizedMetadata.swift b/WebsiteGenerator/Content/Section+LocalizedMetadata.swift deleted file mode 100644 index 26f6d59..0000000 --- a/WebsiteGenerator/Content/Section+LocalizedMetadata.swift +++ /dev/null @@ -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 { - -} diff --git a/WebsiteGenerator/Content/Section+Metadata.swift b/WebsiteGenerator/Content/Section+Metadata.swift deleted file mode 100644 index 74893d0..0000000 --- a/WebsiteGenerator/Content/Section+Metadata.swift +++ /dev/null @@ -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 - } -} diff --git a/WebsiteGenerator/Content/Section.swift b/WebsiteGenerator/Content/Section.swift deleted file mode 100644 index f394b53..0000000 --- a/WebsiteGenerator/Content/Section.swift +++ /dev/null @@ -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 { - -} diff --git a/WebsiteGenerator/Content/Site+LocalizedMetadata.swift b/WebsiteGenerator/Content/Site+LocalizedMetadata.swift deleted file mode 100644 index d266211..0000000 --- a/WebsiteGenerator/Content/Site+LocalizedMetadata.swift +++ /dev/null @@ -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 { - -} diff --git a/WebsiteGenerator/Content/Site+Metadata.swift b/WebsiteGenerator/Content/Site+Metadata.swift deleted file mode 100644 index d53ec85..0000000 --- a/WebsiteGenerator/Content/Site+Metadata.swift +++ /dev/null @@ -1,48 +0,0 @@ -import Foundation - -extension Site { - - struct Metadata { - - let author: String - - let ignoredSubFolders: Set - - 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: "Title", - deployedBaseUrl: "http://example.com", - languages: [.initial]) - } -} diff --git a/WebsiteGenerator/Content/Site.swift b/WebsiteGenerator/Content/Site.swift deleted file mode 100644 index 73700ee..0000000 --- a/WebsiteGenerator/Content/Site.swift +++ /dev/null @@ -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 - } -} diff --git a/WebsiteGenerator/Content/SiteElement.swift b/WebsiteGenerator/Content/SiteElement.swift deleted file mode 100644 index 9b291f8..0000000 --- a/WebsiteGenerator/Content/SiteElement.swift +++ /dev/null @@ -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 `

` 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() } - } -} diff --git a/WebsiteGenerator/ThumbnailStyle.swift b/WebsiteGenerator/Content/ThumbnailStyle.swift similarity index 100% rename from WebsiteGenerator/ThumbnailStyle.swift rename to WebsiteGenerator/Content/ThumbnailStyle.swift diff --git a/WebsiteGenerator/Extensions/Data+Extensions.swift b/WebsiteGenerator/Extensions/Data+Extensions.swift new file mode 100644 index 0000000..18deb25 --- /dev/null +++ b/WebsiteGenerator/Extensions/Data+Extensions.swift @@ -0,0 +1,9 @@ +import Foundation + +extension Data { + + func createFolderAndWrite(to url: URL) throws { + try url.ensureParentFolderExistence() + try write(to: url) + } +} diff --git a/WebsiteGenerator/Extensions/NSImage+Extensions.swift b/WebsiteGenerator/Extensions/NSImage+Extensions.swift new file mode 100644 index 0000000..a633e5c --- /dev/null +++ b/WebsiteGenerator/Extensions/NSImage+Extensions.swift @@ -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 + } + } +} diff --git a/WebsiteGenerator/Extensions/NSSize+Extensions.swift b/WebsiteGenerator/Extensions/NSSize+Extensions.swift new file mode 100644 index 0000000..603b8fd --- /dev/null +++ b/WebsiteGenerator/Extensions/NSSize+Extensions.swift @@ -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 + } +} diff --git a/WebsiteGenerator/Extensions/String+Extensions.swift b/WebsiteGenerator/Extensions/String+Extensions.swift index 54d59e9..6b5dd4e 100644 --- a/WebsiteGenerator/Extensions/String+Extensions.swift +++ b/WebsiteGenerator/Extensions/String+Extensions.swift @@ -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) + } +} diff --git a/WebsiteGenerator/Extensions/URL+Extensions.swift b/WebsiteGenerator/Extensions/URL+Extensions.swift new file mode 100644 index 0000000..9422312 --- /dev/null +++ b/WebsiteGenerator/Extensions/URL+Extensions.swift @@ -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) + } +} diff --git a/WebsiteGenerator/FileProcessor.swift b/WebsiteGenerator/FileProcessor.swift deleted file mode 100644 index 93b9925..0000000 --- a/WebsiteGenerator/FileProcessor.swift +++ /dev/null @@ -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 = [ - "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 = [] - - 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 - } -} diff --git a/WebsiteGenerator/FileSystem.swift b/WebsiteGenerator/FileSystem.swift deleted file mode 100644 index ee04d1a..0000000 --- a/WebsiteGenerator/FileSystem.swift +++ /dev/null @@ -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) - } - } -} diff --git a/WebsiteGenerator/Generic/ContentError.swift b/WebsiteGenerator/Files/ContentError.swift similarity index 100% rename from WebsiteGenerator/Generic/ContentError.swift rename to WebsiteGenerator/Files/ContentError.swift diff --git a/WebsiteGenerator/Files/FileSystem.swift b/WebsiteGenerator/Files/FileSystem.swift new file mode 100644 index 0000000..7dd47b0 --- /dev/null +++ b/WebsiteGenerator/Files/FileSystem.swift @@ -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 = [] + + /** + 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() + } +} diff --git a/WebsiteGenerator/Files/ImageOutput.swift b/WebsiteGenerator/Files/ImageOutput.swift new file mode 100644 index 0000000..bcd9bdd --- /dev/null +++ b/WebsiteGenerator/Files/ImageOutput.swift @@ -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 + } +} diff --git a/WebsiteGenerator/Files/MediaType.swift b/WebsiteGenerator/Files/MediaType.swift new file mode 100644 index 0000000..c442341 --- /dev/null +++ b/WebsiteGenerator/Files/MediaType.swift @@ -0,0 +1,36 @@ +import Foundation +import AppKit + +private let supportedImageExtensions: [String : NSBitmapImageRep.FileType] = [ + "jpg" : .jpeg, + "jpeg" : .jpeg, + "png" : .png, +] + +private let supportedVideoExtensions: Set = [ + "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] + } +} diff --git a/WebsiteGenerator/Generic/ErrorOutput.swift b/WebsiteGenerator/Files/ValidationLog.swift similarity index 97% rename from WebsiteGenerator/Generic/ErrorOutput.swift rename to WebsiteGenerator/Files/ValidationLog.swift index 3732509..1f0715c 100644 --- a/WebsiteGenerator/Generic/ErrorOutput.swift +++ b/WebsiteGenerator/Files/ValidationLog.swift @@ -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 } diff --git a/WebsiteGenerator/VideoType.swift b/WebsiteGenerator/Files/VideoType.swift similarity index 100% rename from WebsiteGenerator/VideoType.swift rename to WebsiteGenerator/Files/VideoType.swift diff --git a/WebsiteGenerator/GenerationError.swift b/WebsiteGenerator/GenerationError.swift deleted file mode 100644 index a3dbb55..0000000 --- a/WebsiteGenerator/GenerationError.swift +++ /dev/null @@ -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(_ error: GenerationError, execute: () throws -> T) rethrows -> T { - do { - return try execute() - } catch let underlyingError { - print(underlyingError) - throw error - } -} - -func wrap(_ 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 - } - } -} diff --git a/WebsiteGenerator/Generators/IndexPageGenerator.swift b/WebsiteGenerator/Generators/IndexPageGenerator.swift index fa69872..0349970 100644 --- a/WebsiteGenerator/Generators/IndexPageGenerator.swift +++ b/WebsiteGenerator/Generators/IndexPageGenerator.swift @@ -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) diff --git a/WebsiteGenerator/Generators/MarkdownProcessor.swift b/WebsiteGenerator/Generators/MarkdownProcessor.swift index 1790e0a..90a2921 100644 --- a/WebsiteGenerator/Generators/MarkdownProcessor.swift +++ b/WebsiteGenerator/Generators/MarkdownProcessor.swift @@ -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 "" diff --git a/WebsiteGenerator/Generators/OverviewPageGenerator.swift b/WebsiteGenerator/Generators/OverviewPageGenerator.swift index 28897d7..d9fba95 100644 --- a/WebsiteGenerator/Generators/OverviewPageGenerator.swift +++ b/WebsiteGenerator/Generators/OverviewPageGenerator.swift @@ -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) } diff --git a/WebsiteGenerator/Generators/OverviewSectionGenerator.swift b/WebsiteGenerator/Generators/OverviewSectionGenerator.swift index b3c27f2..a6a857a 100644 --- a/WebsiteGenerator/Generators/OverviewSectionGenerator.swift +++ b/WebsiteGenerator/Generators/OverviewSectionGenerator.swift @@ -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) } } diff --git a/WebsiteGenerator/Generators/PageGenerator.swift b/WebsiteGenerator/Generators/PageGenerator.swift index fe8b2d8..143f2d3 100644 --- a/WebsiteGenerator/Generators/PageGenerator.swift +++ b/WebsiteGenerator/Generators/PageGenerator.swift @@ -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)\(suffix)" + } } diff --git a/WebsiteGenerator/Generators/PageHeadGenerator.swift b/WebsiteGenerator/Generators/PageHeadGenerator.swift index 0d95f40..e9956d9 100644 --- a/WebsiteGenerator/Generators/PageHeadGenerator.swift +++ b/WebsiteGenerator/Generators/PageHeadGenerator.swift @@ -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] = "" + 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] = "" } - content[.customPageContent] = page.customHeadContent + content[.customPageContent] = page.customHeadContent() return template.generate(content) } diff --git a/WebsiteGenerator/Generators/SiteGenerator.swift b/WebsiteGenerator/Generators/SiteGenerator.swift index ba37078..2d85f2a 100644 --- a/WebsiteGenerator/Generators/SiteGenerator.swift +++ b/WebsiteGenerator/Generators/SiteGenerator.swift @@ -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, diff --git a/WebsiteGenerator/Generators/ThumbnailInfo.swift b/WebsiteGenerator/Generators/ThumbnailInfo.swift deleted file mode 100644 index b62c7ee..0000000 --- a/WebsiteGenerator/Generators/ThumbnailInfo.swift +++ /dev/null @@ -1,14 +0,0 @@ -import Foundation - -struct ThumbnailInfo { - - let url: String? - - let imageFilePath: String - - let imageHtmlUrl: String - - let title: String - - let cornerText: String? -} diff --git a/WebsiteGenerator/Generators/ThumbnailListGenerator.swift b/WebsiteGenerator/Generators/ThumbnailListGenerator.swift index 5649933..ba9034c 100644 --- a/WebsiteGenerator/Generators/ThumbnailListGenerator.swift +++ b/WebsiteGenerator/Generators/ThumbnailListGenerator.swift @@ -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) } } diff --git a/WebsiteGenerator/Generic/Context.swift b/WebsiteGenerator/Generic/Context.swift deleted file mode 100644 index 5e732e4..0000000 --- a/WebsiteGenerator/Generic/Context.swift +++ /dev/null @@ -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) - } -} diff --git a/WebsiteGenerator/Generic/Element.swift b/WebsiteGenerator/Generic/Element.swift deleted file mode 100644 index 89dc2af..0000000 --- a/WebsiteGenerator/Generic/Element.swift +++ /dev/null @@ -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 - - /** - 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 - - /** - 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 + " ") } - } -} diff --git a/WebsiteGenerator/Generic/FileAccess.swift b/WebsiteGenerator/Generic/FileAccess.swift deleted file mode 100644 index 5a231b1..0000000 --- a/WebsiteGenerator/Generic/FileAccess.swift +++ /dev/null @@ -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 = [] - - private var accessedFiles: Set = [] - - 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(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) - } - } - } -} diff --git a/WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift b/WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift index 6bb4691..e1e0c69 100644 --- a/WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift +++ b/WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift @@ -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 { diff --git a/WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift b/WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift index 4e01f51..338e09a 100644 --- a/WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift +++ b/WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift @@ -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 { "\(text)" } @@ -118,7 +98,10 @@ struct LocalizedSiteTemplate { "\(text)" } - 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) } diff --git a/WebsiteGenerator/Templates/Filled/PrefilledTopBarTemplate.swift b/WebsiteGenerator/Templates/Filled/PrefilledTopBarTemplate.swift index a7f6bb6..8cc2b4a 100644 --- a/WebsiteGenerator/Templates/Filled/PrefilledTopBarTemplate.swift +++ b/WebsiteGenerator/Templates/Filled/PrefilledTopBarTemplate.swift @@ -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 diff --git a/WebsiteGenerator/Templates/Template.swift b/WebsiteGenerator/Templates/Template.swift index e59e100..ae13724 100644 --- a/WebsiteGenerator/Templates/Template.swift +++ b/WebsiteGenerator/Templates/Template.swift @@ -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 { diff --git a/WebsiteGenerator/main.swift b/WebsiteGenerator/main.swift index 21a7412..99642c4 100644 --- a/WebsiteGenerator/main.swift +++ b/WebsiteGenerator/main.swift @@ -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()