Update generation
- Move to global objects for files and validation - Only write changed files - Check images for changes before scaling - Simplify code
This commit is contained in:
parent
91d5bcb66d
commit
80d3c08a93
@ -8,22 +8,11 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
E22E8763289D84C300E51191 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8762289D84C300E51191 /* main.swift */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
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 */; };
|
E22E878C289E4A8900E51191 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = E22E878B289E4A8900E51191 /* Ink */; };
|
||||||
E22E8793289E7EC700E51191 /* Page+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */; };
|
E22E8795289E81D700E51191 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8794289E81D700E51191 /* URL+Extensions.swift */; };
|
||||||
E22E8795289E81D700E51191 /* FileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8794289E81D700E51191 /* FileSystem.swift */; };
|
|
||||||
E22E8798289EA42C00E51191 /* FileProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8797289EA42C00E51191 /* FileProcessor.swift */; };
|
|
||||||
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879A289EE02F00E51191 /* Optional+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 */; };
|
E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */; };
|
||||||
E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879F289F008200E51191 /* ThumbnailListGenerator.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 */; };
|
E22E87AC289F1D3700E51191 /* Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AB289F1D3700E51191 /* Template.swift */; };
|
||||||
E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AD289F1E0000E51191 /* String+Extensions.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 */; };
|
E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */; };
|
||||||
E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B1289F296700E51191 /* ThumbnailInfo.swift */; };
|
E253C87728B767D50076B6D0 /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87628B767D50076B6D0 /* MediaType.swift */; };
|
||||||
E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B5289FF67B00E51191 /* Metadata.swift */; };
|
E253C87A28B810090076B6D0 /* ImageOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87928B810090076B6D0 /* ImageOutput.swift */; };
|
||||||
E253C86928AFD86E0076B6D0 /* FileAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C86828AFD86E0076B6D0 /* FileAccess.swift */; };
|
E253C87C28B8BFB80076B6D0 /* FileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87B28B8BFB80076B6D0 /* FileSystem.swift */; };
|
||||||
E253C86B28AFE0980076B6D0 /* Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C86A28AFE0980076B6D0 /* Context.swift */; };
|
E253C87F28B8FBB00076B6D0 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */; };
|
||||||
E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.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 */; };
|
E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */; };
|
||||||
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D628A022C500102A25 /* TemplateFactory.swift */; };
|
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D628A022C500102A25 /* TemplateFactory.swift */; };
|
||||||
E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D828A023FA00102A25 /* PageHeadTemplate.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 */; };
|
E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E228A037F900102A25 /* PageTemplate.swift */; };
|
||||||
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */; };
|
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */; };
|
||||||
E2C5A5E928A0451C00102A25 /* LocalizedSiteTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.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 */; };
|
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 */; };
|
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */; };
|
||||||
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */; };
|
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */; };
|
||||||
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2328ACD0A800632026 /* PageImageTemplate.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 */; };
|
E2F8FA3428AD6F3400632026 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3328AD6F3400632026 /* Element.swift */; };
|
||||||
E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.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 */; };
|
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 */; };
|
E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@ -82,21 +69,10 @@
|
|||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
E22E875F289D84C300E51191 /* WebsiteGenerator */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = WebsiteGenerator; sourceTree = BUILT_PRODUCTS_DIR; };
|
E22E875F289D84C300E51191 /* WebsiteGenerator */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = WebsiteGenerator; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
E22E8762289D84C300E51191 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
E22E8762289D84C300E51191 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||||
E22E8769289D84FD00E51191 /* Section+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+Metadata.swift"; sourceTree = "<group>"; };
|
|
||||||
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailStyle.swift; sourceTree = "<group>"; };
|
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailStyle.swift; sourceTree = "<group>"; };
|
||||||
E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
|
||||||
E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexPageGenerator.swift; sourceTree = "<group>"; };
|
E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexPageGenerator.swift; sourceTree = "<group>"; };
|
||||||
E22E8777289DA0E100E51191 /* GenerationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationError.swift; sourceTree = "<group>"; };
|
|
||||||
E22E8779289DA9F900E51191 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; };
|
|
||||||
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionGenerator.swift; sourceTree = "<group>"; };
|
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionGenerator.swift; sourceTree = "<group>"; };
|
||||||
E22E877E289DC11F00E51191 /* Site+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+Metadata.swift"; sourceTree = "<group>"; };
|
E22E8794289E81D700E51191 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
E22E8781289DCCB600E51191 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = "<group>"; };
|
|
||||||
E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
|
||||||
E22E8786289DDF4C00E51191 /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; };
|
|
||||||
E22E8788289DDF5700E51191 /* Page+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Metadata.swift"; sourceTree = "<group>"; };
|
|
||||||
E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
|
||||||
E22E8794289E81D700E51191 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = "<group>"; };
|
|
||||||
E22E8797289EA42C00E51191 /* FileProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProcessor.swift; sourceTree = "<group>"; };
|
|
||||||
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = "<group>"; };
|
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageGenerator.swift; sourceTree = "<group>"; };
|
E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageGenerator.swift; sourceTree = "<group>"; };
|
||||||
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailListGenerator.swift; sourceTree = "<group>"; };
|
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailListGenerator.swift; sourceTree = "<group>"; };
|
||||||
@ -106,11 +82,12 @@
|
|||||||
E22E87AB289F1D3700E51191 /* Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template.swift; sourceTree = "<group>"; };
|
E22E87AB289F1D3700E51191 /* Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template.swift; sourceTree = "<group>"; };
|
||||||
E22E87AD289F1E0000E51191 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
|
E22E87AD289F1E0000E51191 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefilledTopBarTemplate.swift; sourceTree = "<group>"; };
|
E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefilledTopBarTemplate.swift; sourceTree = "<group>"; };
|
||||||
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailInfo.swift; sourceTree = "<group>"; };
|
E253C87628B767D50076B6D0 /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = "<group>"; };
|
||||||
E22E87B5289FF67B00E51191 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
E253C87928B810090076B6D0 /* ImageOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageOutput.swift; sourceTree = "<group>"; };
|
||||||
E253C86828AFD86E0076B6D0 /* FileAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccess.swift; sourceTree = "<group>"; };
|
E253C87B28B8BFB80076B6D0 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = "<group>"; };
|
||||||
E253C86A28AFE0980076B6D0 /* Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = "<group>"; };
|
E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadataProvider.swift; sourceTree = "<group>"; };
|
E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderTemplate.swift; sourceTree = "<group>"; };
|
E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderTemplate.swift; sourceTree = "<group>"; };
|
||||||
E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = "<group>"; };
|
E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = "<group>"; };
|
||||||
E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadTemplate.swift; sourceTree = "<group>"; };
|
E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadTemplate.swift; sourceTree = "<group>"; };
|
||||||
@ -120,10 +97,7 @@
|
|||||||
E2C5A5E228A037F900102A25 /* PageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTemplate.swift; sourceTree = "<group>"; };
|
E2C5A5E228A037F900102A25 /* PageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTemplate.swift; sourceTree = "<group>"; };
|
||||||
E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationTemplate.swift; sourceTree = "<group>"; };
|
E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationTemplate.swift; sourceTree = "<group>"; };
|
||||||
E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSiteTemplate.swift; sourceTree = "<group>"; };
|
E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSiteTemplate.swift; sourceTree = "<group>"; };
|
||||||
E2C5A5EB28A055E900102A25 /* SiteElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteElement.swift; sourceTree = "<group>"; };
|
|
||||||
E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageContainer.swift; sourceTree = "<group>"; };
|
|
||||||
E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionCleanTemplate.swift; sourceTree = "<group>"; };
|
E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionCleanTemplate.swift; sourceTree = "<group>"; };
|
||||||
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadata.swift; sourceTree = "<group>"; };
|
|
||||||
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = "<group>"; };
|
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = "<group>"; };
|
||||||
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = "<group>"; };
|
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = "<group>"; };
|
||||||
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = "<group>"; };
|
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = "<group>"; };
|
||||||
@ -135,7 +109,7 @@
|
|||||||
E2F8FA3328AD6F3400632026 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = "<group>"; };
|
E2F8FA3328AD6F3400632026 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = "<group>"; };
|
||||||
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Element+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Element+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||||
E2F8FA3728AE27A500632026 /* ContentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentError.swift; sourceTree = "<group>"; };
|
E2F8FA3728AE27A500632026 /* ContentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentError.swift; sourceTree = "<group>"; };
|
||||||
E2F8FA3928AE313A00632026 /* ErrorOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorOutput.swift; sourceTree = "<group>"; };
|
E2F8FA3928AE313A00632026 /* ValidationLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationLog.swift; sourceTree = "<group>"; };
|
||||||
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decodable+Extensions.swift"; sourceTree = "<group>"; };
|
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decodable+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@ -171,17 +145,12 @@
|
|||||||
E22E8761289D84C300E51191 /* WebsiteGenerator */ = {
|
E22E8761289D84C300E51191 /* WebsiteGenerator */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E2F8FA2E28AD44FF00632026 /* Generic */,
|
|
||||||
E22E8762289D84C300E51191 /* main.swift */,
|
E22E8762289D84C300E51191 /* main.swift */,
|
||||||
E22E87A1289F0BF000E51191 /* Content */,
|
E253C87828B80AAF0076B6D0 /* Files */,
|
||||||
|
E2F8FA2E28AD44FF00632026 /* Content */,
|
||||||
E22E87A2289F0C6200E51191 /* Generators */,
|
E22E87A2289F0C6200E51191 /* Generators */,
|
||||||
E2C5A5D328A0222B00102A25 /* Templates */,
|
E2C5A5D328A0222B00102A25 /* Templates */,
|
||||||
E22E8799289EE02300E51191 /* Extensions */,
|
E22E8799289EE02300E51191 /* Extensions */,
|
||||||
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */,
|
|
||||||
E22E8797289EA42C00E51191 /* FileProcessor.swift */,
|
|
||||||
E22E8777289DA0E100E51191 /* GenerationError.swift */,
|
|
||||||
E2F8FA2728ACD84400632026 /* VideoType.swift */,
|
|
||||||
E22E8794289E81D700E51191 /* FileSystem.swift */,
|
|
||||||
);
|
);
|
||||||
path = WebsiteGenerator;
|
path = WebsiteGenerator;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@ -192,36 +161,18 @@
|
|||||||
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */,
|
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */,
|
||||||
E22E87AD289F1E0000E51191 /* String+Extensions.swift */,
|
E22E87AD289F1E0000E51191 /* String+Extensions.swift */,
|
||||||
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */,
|
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */,
|
||||||
|
E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */,
|
||||||
|
E22E8794289E81D700E51191 /* URL+Extensions.swift */,
|
||||||
|
E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */,
|
||||||
|
E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */,
|
||||||
);
|
);
|
||||||
path = Extensions;
|
path = Extensions;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
E22E87A1289F0BF000E51191 /* Content */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
E2C5A5EB28A055E900102A25 /* SiteElement.swift */,
|
|
||||||
E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */,
|
|
||||||
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */,
|
|
||||||
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */,
|
|
||||||
E22E8786289DDF4C00E51191 /* Page.swift */,
|
|
||||||
E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */,
|
|
||||||
E22E8788289DDF5700E51191 /* Page+Metadata.swift */,
|
|
||||||
E22E8781289DCCB600E51191 /* Section.swift */,
|
|
||||||
E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */,
|
|
||||||
E22E8769289D84FD00E51191 /* Section+Metadata.swift */,
|
|
||||||
E22E8779289DA9F900E51191 /* Site.swift */,
|
|
||||||
E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */,
|
|
||||||
E22E877E289DC11F00E51191 /* Site+Metadata.swift */,
|
|
||||||
E22E87B5289FF67B00E51191 /* Metadata.swift */,
|
|
||||||
);
|
|
||||||
path = Content;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
E22E87A2289F0C6200E51191 /* Generators */ = {
|
E22E87A2289F0C6200E51191 /* Generators */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */,
|
E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */,
|
||||||
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */,
|
|
||||||
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */,
|
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */,
|
||||||
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */,
|
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */,
|
||||||
E22E87A3289F0C7000E51191 /* SiteGenerator.swift */,
|
E22E87A3289F0C7000E51191 /* SiteGenerator.swift */,
|
||||||
@ -233,6 +184,19 @@
|
|||||||
path = Generators;
|
path = Generators;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
E253C87828B80AAF0076B6D0 /* Files */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E2F8FA3728AE27A500632026 /* ContentError.swift */,
|
||||||
|
E2F8FA3928AE313A00632026 /* ValidationLog.swift */,
|
||||||
|
E253C87928B810090076B6D0 /* ImageOutput.swift */,
|
||||||
|
E253C87B28B8BFB80076B6D0 /* FileSystem.swift */,
|
||||||
|
E253C87628B767D50076B6D0 /* MediaType.swift */,
|
||||||
|
E2F8FA2728ACD84400632026 /* VideoType.swift */,
|
||||||
|
);
|
||||||
|
path = Files;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
E2C5A5D328A0222B00102A25 /* Templates */ = {
|
E2C5A5D328A0222B00102A25 /* Templates */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -279,20 +243,17 @@
|
|||||||
path = Filled;
|
path = Filled;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
E2F8FA2E28AD44FF00632026 /* Generic */ = {
|
E2F8FA2E28AD44FF00632026 /* Content */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
E253C86A28AFE0980076B6D0 /* Context.swift */,
|
|
||||||
E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */,
|
E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */,
|
||||||
E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */,
|
E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */,
|
||||||
E2F8FA2F28AD450B00632026 /* PageState.swift */,
|
E2F8FA2F28AD450B00632026 /* PageState.swift */,
|
||||||
E2F8FA3328AD6F3400632026 /* Element.swift */,
|
E2F8FA3328AD6F3400632026 /* Element.swift */,
|
||||||
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */,
|
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */,
|
||||||
E2F8FA3728AE27A500632026 /* ContentError.swift */,
|
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */,
|
||||||
E2F8FA3928AE313A00632026 /* ErrorOutput.swift */,
|
|
||||||
E253C86828AFD86E0076B6D0 /* FileAccess.swift */,
|
|
||||||
);
|
);
|
||||||
path = Generic;
|
path = Content;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
@ -363,60 +324,47 @@
|
|||||||
files = (
|
files = (
|
||||||
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */,
|
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */,
|
||||||
E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */,
|
E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */,
|
||||||
E2F8FA3A28AE313A00632026 /* ErrorOutput.swift in Sources */,
|
E2F8FA3A28AE313A00632026 /* ValidationLog.swift in Sources */,
|
||||||
E22E876E289D868100E51191 /* Site+LocalizedMetadata.swift in Sources */,
|
|
||||||
E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */,
|
E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */,
|
||||||
E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */,
|
E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */,
|
||||||
E2F8FA2D28AD2F5300632026 /* GenericMetadata.swift in Sources */,
|
E2F8FA2D28AD2F5300632026 /* GenericMetadata.swift in Sources */,
|
||||||
E22E8798289EA42C00E51191 /* FileProcessor.swift in Sources */,
|
|
||||||
E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */,
|
|
||||||
E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */,
|
E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */,
|
||||||
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */,
|
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */,
|
||||||
E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */,
|
E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */,
|
||||||
E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */,
|
|
||||||
E22E876A289D84FD00E51191 /* Section+Metadata.swift in Sources */,
|
|
||||||
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */,
|
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */,
|
||||||
|
E253C87C28B8BFB80076B6D0 /* FileSystem.swift in Sources */,
|
||||||
E2F8FA3428AD6F3400632026 /* Element.swift in Sources */,
|
E2F8FA3428AD6F3400632026 /* Element.swift in Sources */,
|
||||||
|
E253C87F28B8FBB00076B6D0 /* Data+Extensions.swift in Sources */,
|
||||||
E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */,
|
E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */,
|
||||||
E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */,
|
E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */,
|
||||||
E22E8793289E7EC700E51191 /* Page+LocalizedMetadata.swift in Sources */,
|
|
||||||
E22E877D289DBA0A00E51191 /* OverviewSectionGenerator.swift in Sources */,
|
E22E877D289DBA0A00E51191 /* OverviewSectionGenerator.swift in Sources */,
|
||||||
E22E8782289DCCB600E51191 /* Section.swift in Sources */,
|
|
||||||
E22E877F289DC11F00E51191 /* Site+Metadata.swift in Sources */,
|
|
||||||
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */,
|
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */,
|
||||||
E22E87A4289F0C7000E51191 /* SiteGenerator.swift in Sources */,
|
E22E87A4289F0C7000E51191 /* SiteGenerator.swift in Sources */,
|
||||||
E22E87AC289F1D3700E51191 /* Template.swift in Sources */,
|
E22E87AC289F1D3700E51191 /* Template.swift in Sources */,
|
||||||
E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */,
|
E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */,
|
||||||
E2F8FA3028AD450B00632026 /* PageState.swift in Sources */,
|
E2F8FA3028AD450B00632026 /* PageState.swift in Sources */,
|
||||||
E22E8784289DCD5E00E51191 /* Section+LocalizedMetadata.swift in Sources */,
|
E253C87728B767D50076B6D0 /* MediaType.swift in Sources */,
|
||||||
E22E8789289DDF5700E51191 /* Page+Metadata.swift in Sources */,
|
|
||||||
E2C5A5EC28A055E900102A25 /* SiteElement.swift in Sources */,
|
|
||||||
E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */,
|
E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */,
|
||||||
E22E87A8289F0E7B00E51191 /* PageGenerator.swift in Sources */,
|
E22E87A8289F0E7B00E51191 /* PageGenerator.swift in Sources */,
|
||||||
E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */,
|
E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */,
|
||||||
E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */,
|
E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */,
|
||||||
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */,
|
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */,
|
||||||
|
E253C88328B8FC470076B6D0 /* NSImage+Extensions.swift in Sources */,
|
||||||
E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */,
|
E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */,
|
||||||
E2C5A5DB28A02F9000102A25 /* TopBarTemplate.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 */,
|
E2C5A5E928A0451C00102A25 /* LocalizedSiteTemplate.swift in Sources */,
|
||||||
E2C5A5E128A0373300102A25 /* ThumbnailTemplate.swift in Sources */,
|
E2C5A5E128A0373300102A25 /* ThumbnailTemplate.swift in Sources */,
|
||||||
E22E8795289E81D700E51191 /* FileSystem.swift in Sources */,
|
E22E8795289E81D700E51191 /* URL+Extensions.swift in Sources */,
|
||||||
E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */,
|
E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */,
|
||||||
E22E8763289D84C300E51191 /* main.swift in Sources */,
|
E22E8763289D84C300E51191 /* main.swift in Sources */,
|
||||||
E253C86B28AFE0980076B6D0 /* Context.swift in Sources */,
|
|
||||||
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */,
|
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */,
|
||||||
E22E877A289DA9F900E51191 /* Site.swift in Sources */,
|
|
||||||
E2F8FA3228AD456C00632026 /* GenericMetadata+Localized.swift in Sources */,
|
E2F8FA3228AD456C00632026 /* GenericMetadata+Localized.swift in Sources */,
|
||||||
E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */,
|
|
||||||
E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */,
|
E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */,
|
||||||
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */,
|
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */,
|
||||||
E22E8787289DDF4C00E51191 /* Page.swift in Sources */,
|
E253C87A28B810090076B6D0 /* ImageOutput.swift in Sources */,
|
||||||
E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */,
|
E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */,
|
||||||
E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */,
|
E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */,
|
||||||
E253C86928AFD86E0076B6D0 /* FileAccess.swift in Sources */,
|
E253C88128B8FBFF0076B6D0 /* NSSize+Extensions.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "1340"
|
||||||
|
version = "1.3">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E22E875E289D84C300E51191"
|
||||||
|
BuildableName = "WebsiteGenerator"
|
||||||
|
BlueprintName = "WebsiteGenerator"
|
||||||
|
ReferencedContainer = "container:WebsiteGenerator.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||||
|
<Testables>
|
||||||
|
</Testables>
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E22E875E289D84C300E51191"
|
||||||
|
BuildableName = "WebsiteGenerator"
|
||||||
|
BlueprintName = "WebsiteGenerator"
|
||||||
|
ReferencedContainer = "container:WebsiteGenerator.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "E22E875E289D84C300E51191"
|
||||||
|
BuildableName = "WebsiteGenerator"
|
||||||
|
BlueprintName = "WebsiteGenerator"
|
||||||
|
ReferencedContainer = "container:WebsiteGenerator.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
@ -10,5 +10,13 @@
|
|||||||
<integer>0</integer>
|
<integer>0</integer>
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
|
<key>SuppressBuildableAutocreation</key>
|
||||||
|
<dict>
|
||||||
|
<key>E22E875E289D84C300E51191</key>
|
||||||
|
<dict>
|
||||||
|
<key>primary</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
@ -41,9 +41,9 @@ extension Element {
|
|||||||
/**
|
/**
|
||||||
The file name of the link preview image.
|
The file name of the link preview image.
|
||||||
- Note: The image must be located in the element folder.
|
- 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.
|
The description text for the link preview.
|
||||||
@ -63,10 +63,16 @@ extension Element {
|
|||||||
The text on the back navigation link of **contained** elements.
|
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.
|
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
|
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
|
The text to show as a title for placeholder boxes
|
||||||
|
|
||||||
@ -118,8 +124,7 @@ extension Element {
|
|||||||
|
|
||||||
extension Element.LocalizedMetadata {
|
extension Element.LocalizedMetadata {
|
||||||
|
|
||||||
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata, with context: Context) {
|
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata) {
|
||||||
let validation = context.validation
|
|
||||||
// Go through all elements and check them for completeness
|
// Go through all elements and check them for completeness
|
||||||
// In the end, check that all required elements are present
|
// In the end, check that all required elements are present
|
||||||
var isComplete = true
|
var isComplete = true
|
||||||
@ -127,43 +132,43 @@ extension Element.LocalizedMetadata {
|
|||||||
isComplete = false
|
isComplete = false
|
||||||
}
|
}
|
||||||
let source = "root"
|
let source = "root"
|
||||||
self.language = validation
|
self.language = log
|
||||||
.required(data.language, name: "language", source: source)
|
.required(data.language, name: "language", source: source)
|
||||||
.ifNil(markAsIncomplete) ?? ""
|
.ifNil(markAsIncomplete) ?? ""
|
||||||
self.title = validation
|
self.title = log
|
||||||
.required(data.title, name: "title", source: source)
|
.required(data.title, name: "title", source: source)
|
||||||
.ifNil(markAsIncomplete) ?? ""
|
.ifNil(markAsIncomplete) ?? ""
|
||||||
self.subtitle = data.subtitle
|
self.subtitle = data.subtitle
|
||||||
self.description = data.description
|
self.description = data.description
|
||||||
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
|
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
|
||||||
self.linkPreviewImage = validation
|
self.linkPreviewImage = log
|
||||||
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source) ?? ""
|
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
|
||||||
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
|
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
|
||||||
self.linkPreviewDescription = validation
|
self.linkPreviewDescription = log
|
||||||
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
||||||
.ifNil(markAsIncomplete) ?? ""
|
.ifNil(markAsIncomplete) ?? ""
|
||||||
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
|
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
|
||||||
self.backLinkText = validation
|
self.backLinkText = log
|
||||||
.required(data.backLinkText, name: "backLinkText", source: source)
|
.required(data.backLinkText, name: "backLinkText", source: source)
|
||||||
.ifNil(markAsIncomplete) ?? ""
|
.ifNil(markAsIncomplete) ?? ""
|
||||||
self.placeholderTitle = validation
|
self.parentBackLinkText = "" // Root has no parent
|
||||||
|
self.placeholderTitle = log
|
||||||
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
|
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
|
||||||
.ifNil(markAsIncomplete) ?? ""
|
.ifNil(markAsIncomplete) ?? ""
|
||||||
self.placeholderText = validation
|
self.placeholderText = log
|
||||||
.required(data.placeholderText, name: "placeholderText", source: source)
|
.required(data.placeholderText, name: "placeholderText", source: source)
|
||||||
.ifNil(markAsIncomplete) ?? ""
|
.ifNil(markAsIncomplete) ?? ""
|
||||||
self.titleSuffix = data.titleSuffix
|
self.titleSuffix = data.titleSuffix
|
||||||
self.thumbnailSuffix = validation.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
|
self.thumbnailSuffix = log.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
|
||||||
self.cornerText = validation.unused(data.cornerText, "cornerText", source: source)
|
self.cornerText = log.unused(data.cornerText, "cornerText", source: source)
|
||||||
self.externalUrl = validation.unexpected(data.externalUrl, name: "externalUrl", source: source)
|
self.externalUrl = log.unexpected(data.externalUrl, name: "externalUrl", source: source)
|
||||||
|
|
||||||
guard isComplete else {
|
guard isComplete else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata, with context: Context) {
|
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata) {
|
||||||
let validation = context.validation
|
|
||||||
// Go through all elements and check them for completeness
|
// Go through all elements and check them for completeness
|
||||||
// In the end, check that all required elements are present
|
// In the end, check that all required elements are present
|
||||||
var isComplete = true
|
var isComplete = true
|
||||||
@ -171,20 +176,21 @@ extension Element.LocalizedMetadata {
|
|||||||
isComplete = false
|
isComplete = false
|
||||||
}
|
}
|
||||||
self.language = parent.language
|
self.language = parent.language
|
||||||
self.title = validation
|
self.title = log
|
||||||
.required(data.title, name: "title", source: source)
|
.required(data.title, name: "title", source: source)
|
||||||
.ifNil(markAsIncomplete) ?? ""
|
.ifNil(markAsIncomplete) ?? ""
|
||||||
self.subtitle = data.subtitle
|
self.subtitle = data.subtitle
|
||||||
self.description = data.description
|
self.description = data.description
|
||||||
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
|
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
|
||||||
self.linkPreviewImage = validation
|
self.linkPreviewImage = log
|
||||||
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source) ?? ""
|
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
|
||||||
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
|
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
|
||||||
self.linkPreviewDescription = validation
|
self.linkPreviewDescription = log
|
||||||
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
||||||
.ifNil(markAsIncomplete) ?? ""
|
.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.backLinkText = data.backLinkText ?? parent.backLinkText
|
||||||
|
self.parentBackLinkText = parent.backLinkText
|
||||||
self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle
|
self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle
|
||||||
self.placeholderText = data.placeholderText ?? parent.placeholderText
|
self.placeholderText = data.placeholderText ?? parent.placeholderText
|
||||||
self.titleSuffix = data.titleSuffix
|
self.titleSuffix = data.titleSuffix
|
429
WebsiteGenerator/Content/Element.swift
Normal file
429
WebsiteGenerator/Content/Element.swift
Normal file
@ -0,0 +1,429 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Element {
|
||||||
|
|
||||||
|
static let overviewItemCountDefault = 6
|
||||||
|
|
||||||
|
/**
|
||||||
|
The author of the content.
|
||||||
|
|
||||||
|
If no author is set, then the author from the parent element is used.
|
||||||
|
*/
|
||||||
|
let author: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
The title used in the top bar of the website, next to the logo.
|
||||||
|
|
||||||
|
This title can be HTML content, and only the root level value is used.
|
||||||
|
*/
|
||||||
|
let topBarTitle: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
The url where the site will be deployed.
|
||||||
|
|
||||||
|
This value is required to build absolute links for link previews.
|
||||||
|
|
||||||
|
- Note: Only the root level value is used.
|
||||||
|
- Note: The path does not need to contain a trailing slash.
|
||||||
|
*/
|
||||||
|
let deployedBaseUrl: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
The (start) date of the element.
|
||||||
|
|
||||||
|
The date is printed on content pages and may also used for sorting elements,
|
||||||
|
depending on the `useManualSorting` property of the parent.
|
||||||
|
*/
|
||||||
|
let date: Date?
|
||||||
|
|
||||||
|
/**
|
||||||
|
The end date of the element.
|
||||||
|
|
||||||
|
This property can be used to specify a date range for a content page.
|
||||||
|
*/
|
||||||
|
let endDate: Date?
|
||||||
|
|
||||||
|
/**
|
||||||
|
The deployment state of the page.
|
||||||
|
|
||||||
|
- Note: This property defaults to ``PageState.standard`
|
||||||
|
*/
|
||||||
|
let state: PageState
|
||||||
|
|
||||||
|
/**
|
||||||
|
The sort index of the page for manual sorting.
|
||||||
|
|
||||||
|
- Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set.
|
||||||
|
*/
|
||||||
|
let sortIndex: Int?
|
||||||
|
|
||||||
|
/**
|
||||||
|
All files which may occur in content but is stored externally.
|
||||||
|
|
||||||
|
Missing files which would otherwise produce a warning are ignored when included here.
|
||||||
|
- Note: This property defaults to an empty set.
|
||||||
|
*/
|
||||||
|
let externalFiles: Set<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
Specifies additional files which should be copied to the destination when generating the content.
|
||||||
|
- Note: This property defaults to an empty set.
|
||||||
|
*/
|
||||||
|
let requiredFiles: Set<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
The style of thumbnail to use when generating overviews.
|
||||||
|
|
||||||
|
- Note: This property is only relevant for sections.
|
||||||
|
- Note: This property is inherited from the parent if not specified.
|
||||||
|
*/
|
||||||
|
let thumbnailStyle: ThumbnailStyle
|
||||||
|
|
||||||
|
/**
|
||||||
|
Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`.
|
||||||
|
|
||||||
|
- Note: This property is only relevant for sections.
|
||||||
|
- Note: This property defaults to `false`
|
||||||
|
*/
|
||||||
|
let useManualSorting: Bool
|
||||||
|
|
||||||
|
/**
|
||||||
|
The number of items to show when generating overviews of this element.
|
||||||
|
- Note: This property is only relevant for sections.
|
||||||
|
- Note: This property is inherited from the parent if not specified.
|
||||||
|
*/
|
||||||
|
let overviewItemCount: Int
|
||||||
|
|
||||||
|
/**
|
||||||
|
Indicate that no header should be generated automatically.
|
||||||
|
|
||||||
|
This option assumes that custom header code is present in the page source files
|
||||||
|
- Note: If not specified, this property defaults to `false`.
|
||||||
|
*/
|
||||||
|
let useCustomHeader: Bool
|
||||||
|
|
||||||
|
/**
|
||||||
|
The localized metadata for each language.
|
||||||
|
*/
|
||||||
|
let languages: [LocalizedMetadata]
|
||||||
|
|
||||||
|
/**
|
||||||
|
All elements contained within the element.
|
||||||
|
|
||||||
|
If the element is a section, then this property contains the pages or subsections within.
|
||||||
|
*/
|
||||||
|
var elements: [Element] = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
The url of the element's folder in the source hierarchy.
|
||||||
|
- Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property.
|
||||||
|
*/
|
||||||
|
let inputFolder: URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
The path to the element's folder in the source hierarchy (without a leading slash).
|
||||||
|
*/
|
||||||
|
let path: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create the root element of a site.
|
||||||
|
|
||||||
|
The root element will recursively move into subfolders and build the site content
|
||||||
|
by looking for metadata files in each subfolder.
|
||||||
|
- Parameter folder: The root folder of the site content.
|
||||||
|
- Note: Uses global objects.
|
||||||
|
*/
|
||||||
|
init?(atRoot folder: URL) throws {
|
||||||
|
self.inputFolder = folder
|
||||||
|
self.path = ""
|
||||||
|
|
||||||
|
let source = GenericMetadata.metadataFileName
|
||||||
|
guard let metadata = GenericMetadata(source: source) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.author = log.required(metadata.author, name: "author", source: source) ?? "author"
|
||||||
|
self.topBarTitle = log
|
||||||
|
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
|
||||||
|
self.deployedBaseUrl = log
|
||||||
|
.required(metadata.deployedBaseUrl, name: "deployedBaseUrl", source: source) ?? "https://example.com"
|
||||||
|
self.date = log.unused(metadata.date, "date", source: source)
|
||||||
|
self.endDate = log.unused(metadata.endDate, "endDate", source: source)
|
||||||
|
self.state = log.state(metadata.state, source: source)
|
||||||
|
self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source)
|
||||||
|
self.externalFiles = metadata.externalFiles ?? []
|
||||||
|
self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root
|
||||||
|
self.thumbnailStyle = log.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large
|
||||||
|
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true
|
||||||
|
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
|
||||||
|
self.useCustomHeader = metadata.useCustomHeader ?? false
|
||||||
|
self.languages = log.required(metadata.languages, name: "languages", source: source)?
|
||||||
|
.compactMap { language in
|
||||||
|
.init(atRoot: folder, data: language)
|
||||||
|
} ?? []
|
||||||
|
try self.readElements(in: folder, source: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func readElements(in folder: URL, source: String?) throws {
|
||||||
|
let subFolders: [URL]
|
||||||
|
do {
|
||||||
|
subFolders = try FileManager.default
|
||||||
|
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
|
||||||
|
.filter { $0.isDirectory }
|
||||||
|
} catch {
|
||||||
|
log.add(error: "Failed to read subfolders", source: source ?? "root", error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.elements = try subFolders.compactMap { subFolder in
|
||||||
|
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent
|
||||||
|
return try Element(parent: self, folder: subFolder, path: s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(parent: Element, folder: URL, path: String) throws {
|
||||||
|
self.inputFolder = folder
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
let source = path + "/" + GenericMetadata.metadataFileName
|
||||||
|
guard let metadata = GenericMetadata(source: source) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.author = metadata.author ?? parent.author
|
||||||
|
self.topBarTitle = log
|
||||||
|
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
|
||||||
|
self.deployedBaseUrl = log
|
||||||
|
.unused(metadata.deployedBaseUrl, "deployedBaseUrl", source: source) ?? parent.deployedBaseUrl
|
||||||
|
let date = log.date(from: metadata.date, property: "date", source: source).ifNil {
|
||||||
|
if !parent.useManualSorting {
|
||||||
|
log.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.date = date
|
||||||
|
self.endDate = log.date(from: metadata.endDate, property: "endDate", source: source).ifNotNil {
|
||||||
|
if date == nil {
|
||||||
|
log.add(warning: "Set 'endDate', but no 'date'", source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.state = log.state(metadata.state, source: source)
|
||||||
|
self.sortIndex = metadata.sortIndex.ifNil {
|
||||||
|
if parent.useManualSorting {
|
||||||
|
log.add(error: "No 'sortIndex', but parent defines 'useManualSorting' = true", source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// TODO: Propagate external files from the parent if subpath matches?
|
||||||
|
self.externalFiles = metadata.externalFiles ?? []
|
||||||
|
self.requiredFiles = Set((metadata.requiredFiles ?? []).map { path + "/" + $0 })
|
||||||
|
self.thumbnailStyle = log.thumbnailStyle(metadata.thumbnailStyle, source: source)
|
||||||
|
self.useManualSorting = metadata.useManualSorting ?? false
|
||||||
|
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
|
||||||
|
self.useCustomHeader = metadata.useCustomHeader ?? false
|
||||||
|
self.languages = parent.languages.compactMap { parentData in
|
||||||
|
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
|
||||||
|
log.add(info: "Language '\(parentData.language)' not found", source: source)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return .init(folder: folder, data: data, source: source, parent: parentData)
|
||||||
|
}
|
||||||
|
// Check that each 'language' tag is present, and that all languages appear in the parent
|
||||||
|
log.required(metadata.languages, name: "languages", source: source)?
|
||||||
|
.compactMap { log.required($0.language, name: "language", source: source) }
|
||||||
|
.filter { language in
|
||||||
|
!parent.languages.contains { $0.language == language }
|
||||||
|
}
|
||||||
|
.forEach {
|
||||||
|
log.add(warning: "Language '\($0)' not found in parent, so not generated", source: source)
|
||||||
|
}
|
||||||
|
try self.readElements(in: folder, source: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Paths
|
||||||
|
|
||||||
|
extension Element {
|
||||||
|
|
||||||
|
var containsElements: Bool {
|
||||||
|
!elements.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasNestingElements: Bool {
|
||||||
|
elements.contains { $0.containsElements }
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortedItems: [Element] {
|
||||||
|
if useManualSorting {
|
||||||
|
return elements.sorted { $0.sortIndex! < $1.sortIndex! }
|
||||||
|
}
|
||||||
|
return elements.sorted { $0.date! > $1.date! }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The url of the top-level section of the element.
|
||||||
|
*/
|
||||||
|
func sectionUrl(for language: String) -> String {
|
||||||
|
path.components(separatedBy: "/").first! + "/\(language).html"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
|
||||||
|
|
||||||
|
This function is used to copy required input files and to generate images
|
||||||
|
*/
|
||||||
|
func pathRelativeToRootForContainedInputFile(_ filePath: String) -> String {
|
||||||
|
guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
return "\(path)/\(filePath)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func relativePathToFileWithPath(_ filePath: String) -> String {
|
||||||
|
guard path != "" else {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
guard filePath.hasPrefix(path) else {
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
return filePath.replacingOccurrences(of: path + "/", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Accessing localizations
|
||||||
|
|
||||||
|
extension Element {
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get the full path of the thumbnail image for the language (relative to the root folder).
|
||||||
|
*/
|
||||||
|
func thumbnailFilePath(for language: String) -> String {
|
||||||
|
guard let thumbnailFile = Element.findThumbnail(for: language, in: inputFolder) else {
|
||||||
|
fatalError()
|
||||||
|
}
|
||||||
|
return pathRelativeToRootForContainedInputFile(thumbnailFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fullPageUrl(for language: String) -> String {
|
||||||
|
localized(for: language).externalUrl ?? localizedPath(for: language)
|
||||||
|
}
|
||||||
|
|
||||||
|
func localized(for language: String) -> LocalizedMetadata {
|
||||||
|
languages.first { $0.language == language }!
|
||||||
|
}
|
||||||
|
|
||||||
|
func title(for language: String) -> String {
|
||||||
|
localized(for: language).title
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get the back link text for the element.
|
||||||
|
|
||||||
|
This text is the one printed for pages of the element, which uses the back text specified by the parent.
|
||||||
|
*/
|
||||||
|
func backLinkText(for language: String) -> String {
|
||||||
|
localized(for: language).parentBackLinkText
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The optional text to display in a thumbnail corner.
|
||||||
|
- Note: This text is only displayed for large thumbnails.
|
||||||
|
*/
|
||||||
|
func cornerText(for language: String) -> String? {
|
||||||
|
localized(for: language).cornerText
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Returns the full path (relative to the site root for a page of the element in the given language.
|
||||||
|
*/
|
||||||
|
func localizedPath(for language: String) -> String {
|
||||||
|
path != "" ? "\(path)/\(language).html" : "\(language).html"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get the next language to switch to with the language button.
|
||||||
|
*/
|
||||||
|
func nextLanguage(for languageIdentifier: String) -> String? {
|
||||||
|
let langs = languages.map { $0.language }
|
||||||
|
guard let index = langs.firstIndex(of: languageIdentifier) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for i in 1..<langs.count {
|
||||||
|
let next = langs[(index + i) % langs.count]
|
||||||
|
guard hasContent(for: next) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard next != languageIdentifier else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func linkPreviewImage(for language: String) -> String? {
|
||||||
|
localized(for: language).linkPreviewImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Page content
|
||||||
|
|
||||||
|
extension Element {
|
||||||
|
|
||||||
|
var isExternalPage: Bool {
|
||||||
|
languages.contains { $0.externalUrl != nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get the url of the content markdown file for a language.
|
||||||
|
|
||||||
|
To check if the file also exists, use `existingContentUrl(for:)`
|
||||||
|
*/
|
||||||
|
private func contentUrl(for language: String) -> URL {
|
||||||
|
inputFolder.appendingPathComponent("\(language).md")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get the url of existing markdown content for a language.
|
||||||
|
*/
|
||||||
|
private func existingContentUrl(for language: String) -> URL? {
|
||||||
|
let url = contentUrl(for: language)
|
||||||
|
guard url.exists else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasContent(for language: String) -> Bool {
|
||||||
|
existingContentUrl(for: language) != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Header and Footer
|
||||||
|
|
||||||
|
extension Element {
|
||||||
|
|
||||||
|
private var additionalHeadContentPath: String {
|
||||||
|
path + "/head.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
func customHeadContent() -> String? {
|
||||||
|
files.contentOfOptionalFile(atPath: additionalHeadContentPath, source: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var additionalFooterContentPath: String {
|
||||||
|
path + "/footer.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
func customFooterContent() -> String? {
|
||||||
|
files.contentOfOptionalFile(atPath: additionalFooterContentPath, source: path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Debug
|
||||||
|
|
||||||
|
extension Element {
|
||||||
|
|
||||||
|
func printTree(indentation: String = "") {
|
||||||
|
print(indentation + "/" + path)
|
||||||
|
elements.forEach { $0.printTree(indentation: indentation + " ") }
|
||||||
|
}
|
||||||
|
}
|
@ -100,6 +100,14 @@ struct GenericMetadata {
|
|||||||
*/
|
*/
|
||||||
let overviewItemCount: Int?
|
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.
|
The localized metadata for each language.
|
||||||
*/
|
*/
|
||||||
@ -122,7 +130,8 @@ extension GenericMetadata: Codable {
|
|||||||
.thumbnailStyle,
|
.thumbnailStyle,
|
||||||
.useManualSorting,
|
.useManualSorting,
|
||||||
.overviewItemCount,
|
.overviewItemCount,
|
||||||
.languages
|
.useCustomHeader,
|
||||||
|
.languages,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,12 +145,13 @@ extension GenericMetadata {
|
|||||||
/**
|
/**
|
||||||
Decode metadata in a folder.
|
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 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: 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 {
|
init?(source: String) {
|
||||||
guard let data = try context.fileSystem.loadDataContent(inputPath: source) else {
|
guard let data = files.dataOfOptionalFile(atPath: source, source: source) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,20 +164,21 @@ extension GenericMetadata {
|
|||||||
// Only one key means we are decoding the generic metadata
|
// Only one key means we are decoding the generic metadata
|
||||||
guard keys.count > 1 else {
|
guard keys.count > 1 else {
|
||||||
if !knownKeys.contains(key.stringValue) {
|
if !knownKeys.contains(key.stringValue) {
|
||||||
context.validation.unknown(property: key.stringValue, source: source)
|
log.unknown(property: key.stringValue, source: source)
|
||||||
}
|
}
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
// Two levels means we're decoding the localized metadata
|
// Two levels means we're decoding the localized metadata
|
||||||
if !knownLocalizedKeys.contains(key.stringValue) {
|
if !knownLocalizedKeys.contains(key.stringValue) {
|
||||||
context.validation.unknown(property: key.stringValue, source: source)
|
log.unknown(property: key.stringValue, source: source)
|
||||||
}
|
}
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
self = try decoder.decode(from: data)
|
self = try decoder.decode(from: data)
|
||||||
} catch {
|
} catch {
|
||||||
context.validation.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
|
print("Here \(data)")
|
||||||
|
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,119 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
protocol LanguageIdentifiable {
|
|
||||||
|
|
||||||
var languageIdentifier: String { get }
|
|
||||||
|
|
||||||
var title: String { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol LanguageContainer {
|
|
||||||
|
|
||||||
associatedtype LocalizedContainer: LanguageIdentifiable
|
|
||||||
|
|
||||||
var languages: [LocalizedContainer] { get }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
protocol LocalizedMetadataContainer {
|
|
||||||
|
|
||||||
associatedtype MetadataType: LanguageContainer
|
|
||||||
|
|
||||||
var metadata: MetadataType { get }
|
|
||||||
|
|
||||||
func hasContent(for language: String) -> Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Default implementations
|
|
||||||
|
|
||||||
extension LocalizedMetadataContainer {
|
|
||||||
|
|
||||||
func hasContent(for language: String) -> Bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Extensions
|
|
||||||
|
|
||||||
extension LocalizedMetadataContainer {
|
|
||||||
|
|
||||||
func localized(for language: String) -> MetadataType.LocalizedContainer {
|
|
||||||
metadata.localized(for: language)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
The localized title of the element.
|
|
||||||
|
|
||||||
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
|
|
||||||
*/
|
|
||||||
func title(for language: String) -> String {
|
|
||||||
localized(for: language).title
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
func nextLanguage(for languageIdentifier: String) -> String? {
|
|
||||||
let langs = metadata.languages.map { $0.languageIdentifier }
|
|
||||||
guard let index = langs.firstIndex(of: languageIdentifier) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for i in 1..<langs.count {
|
|
||||||
let next = langs[(index + i) % langs.count]
|
|
||||||
guard hasContent(for: next) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
guard next != languageIdentifier else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension LanguageContainer {
|
|
||||||
|
|
||||||
var languageIdentifiers: [String] {
|
|
||||||
languages.map { $0.languageIdentifier }
|
|
||||||
}
|
|
||||||
|
|
||||||
#warning("Throw better error for missing language")
|
|
||||||
func localized(for language: String) -> LocalizedContainer {
|
|
||||||
languages.first { $0.languageIdentifier == language }!
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
The localized title of the element.
|
|
||||||
|
|
||||||
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
|
|
||||||
*/
|
|
||||||
func title(for language: String) -> String {
|
|
||||||
localized(for: language).title
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension LocalizedMetadataContainer where Self: SiteElement, Self.MetadataType.LocalizedContainer: LinkPreviewMetadataProvider {
|
|
||||||
|
|
||||||
private func linkPreviewImageFileName(for language: String) -> String? {
|
|
||||||
if let fileName = localized(for: language).linkPreview?.image {
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
// Check for the existence of a localized thumbnail
|
|
||||||
let fileName = Self.thumbnailFileNameLocalized(for: language)
|
|
||||||
if inputFolder.appendingPathComponent(fileName).exists {
|
|
||||||
return fileName
|
|
||||||
}
|
|
||||||
let defaultThumbnail = Self.defaultThumbnailFileName
|
|
||||||
if inputFolder.appendingPathComponent(defaultThumbnail).exists {
|
|
||||||
return defaultThumbnail
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func linkPreviewImage(for language: String) -> String? {
|
|
||||||
guard let fileName = linkPreviewImageFileName(for: language) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return "/\(path)/\(fileName)"
|
|
||||||
}
|
|
||||||
}
|
|
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 }
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
@ -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 {
|
|
||||||
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -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 {
|
|
||||||
|
|
||||||
}
|
|
@ -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 {
|
|
||||||
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
extension Site {
|
|
||||||
|
|
||||||
struct Metadata {
|
|
||||||
|
|
||||||
let author: String
|
|
||||||
|
|
||||||
let ignoredSubFolders: Set<String>
|
|
||||||
|
|
||||||
let topBarTitle: String?
|
|
||||||
|
|
||||||
/**
|
|
||||||
The url where the site will be deployed.
|
|
||||||
|
|
||||||
This value is required to build absolute links for link previews.
|
|
||||||
- Note: The path does not need to contain a trailing slash.
|
|
||||||
*/
|
|
||||||
let deployedBaseUrl: String
|
|
||||||
|
|
||||||
let languages: [LocalizedMetadata]
|
|
||||||
|
|
||||||
static func write(to url: URL) throws {
|
|
||||||
try Metadata.initial.writeJSON(to: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Site.Metadata: LanguageContainer {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Site.Metadata: Codable {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Site.Metadata: Metadata {
|
|
||||||
|
|
||||||
static let fileName = "site.json"
|
|
||||||
|
|
||||||
static var initial: Self {
|
|
||||||
.init(author: "Author",
|
|
||||||
ignoredSubFolders: ["templates"],
|
|
||||||
topBarTitle: "<b>Title</b>",
|
|
||||||
deployedBaseUrl: "http://example.com",
|
|
||||||
languages: [.initial])
|
|
||||||
}
|
|
||||||
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,199 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
protocol SiteElement {
|
|
||||||
|
|
||||||
/**
|
|
||||||
The sort index for the element when manual sorting is specified for the parent.
|
|
||||||
- Note: Elements are sorted in ascending order.
|
|
||||||
*/
|
|
||||||
var sortIndex: Int? { get }
|
|
||||||
|
|
||||||
/**
|
|
||||||
The date used for sorting of the element, if automatic sorting is specified by the parent.
|
|
||||||
- Note: Elements are sorted by newest first.
|
|
||||||
*/
|
|
||||||
var sortDate: Date? { get }
|
|
||||||
|
|
||||||
/**
|
|
||||||
The path to the element's folder in the source hierarchy (without a leading slash).
|
|
||||||
*/
|
|
||||||
var path: String { get }
|
|
||||||
|
|
||||||
/**
|
|
||||||
The url of the element's folder in the source hierarchy.
|
|
||||||
- Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property.
|
|
||||||
*/
|
|
||||||
var inputFolder: URL { get }
|
|
||||||
|
|
||||||
/**
|
|
||||||
The localized title of the element.
|
|
||||||
|
|
||||||
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
|
|
||||||
*/
|
|
||||||
func title(for language: String) -> String
|
|
||||||
|
|
||||||
/**
|
|
||||||
The optional text to display in a thumbnail corner.
|
|
||||||
- Note: This text is only displayed for large thumbnails.
|
|
||||||
*/
|
|
||||||
func cornerText(for language: String) -> String?
|
|
||||||
|
|
||||||
/**
|
|
||||||
The url to the element in the given language.
|
|
||||||
|
|
||||||
If the `externalUrl` property is not set for the page metadata in the given language, then the standard path is returned.
|
|
||||||
- If this value starts with a slash, it is considered an absolute url to the same domain
|
|
||||||
- If the value starts with `http://` or `https://` it is considered an external url
|
|
||||||
- Otherwise the value is treated as a path from the root of the site.
|
|
||||||
*/
|
|
||||||
func fullPageUrl(for language: String) -> String
|
|
||||||
|
|
||||||
/**
|
|
||||||
All elements contained within the element.
|
|
||||||
|
|
||||||
If the element is a section, then this property contains the pages within.
|
|
||||||
*/
|
|
||||||
var elements: [SiteElement] { get }
|
|
||||||
|
|
||||||
func backLinkText(for language: String) throws -> String?
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SiteElement {
|
|
||||||
|
|
||||||
func fullPageUrl(for language: String) -> String {
|
|
||||||
localizedPath(for: language)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SiteElement {
|
|
||||||
|
|
||||||
/**
|
|
||||||
The id of the section to which this element contains.
|
|
||||||
|
|
||||||
This property is used to highlight the active section in the top bar.
|
|
||||||
|
|
||||||
The section id is the folder name of the top-level section
|
|
||||||
*/
|
|
||||||
var sectionId: String {
|
|
||||||
path.components(separatedBy: "/").first!
|
|
||||||
}
|
|
||||||
|
|
||||||
static var defaultThumbnailFileName: String { "thumbnail.jpg" }
|
|
||||||
|
|
||||||
static func thumbnailFileNameLocalized(for language: String) -> String {
|
|
||||||
defaultThumbnailFileName.insert("-\(language)", beforeLast: ".")
|
|
||||||
}
|
|
||||||
|
|
||||||
var containedFolder: String {
|
|
||||||
inputFolder.lastPathComponent
|
|
||||||
}
|
|
||||||
|
|
||||||
var containsElements: Bool {
|
|
||||||
!elements.isEmpty
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasNestingElements: Bool {
|
|
||||||
elements.contains { $0.containsElements }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Get the full path of the thumbnail image for the language (relative to the root folder).
|
|
||||||
*/
|
|
||||||
func thumbnailFilePath(for language: String) -> String {
|
|
||||||
let specificImageName = Self.thumbnailFileNameLocalized(for: language)
|
|
||||||
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
|
|
||||||
guard specificImageUrl.exists else {
|
|
||||||
return "\(path)/\(Self.defaultThumbnailFileName)"
|
|
||||||
}
|
|
||||||
return "\(path)/\(specificImageName)"
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
Gets the thumbnail image for the element.
|
|
||||||
|
|
||||||
If a localized thumbnail exists, then this image name is returned.
|
|
||||||
*/
|
|
||||||
func thumbnailName(for language: String) -> String {
|
|
||||||
let specificImageName = "thumbnail-\(language).jpg"
|
|
||||||
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
|
|
||||||
guard specificImageUrl.exists else {
|
|
||||||
return "\(inputFolder.lastPathComponent)/thumbnail.jpg"
|
|
||||||
}
|
|
||||||
return "\(inputFolder.lastPathComponent)/\(specificImageName)"
|
|
||||||
}
|
|
||||||
/**
|
|
||||||
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
|
|
||||||
|
|
||||||
This function is used to copy required input files and to generate images
|
|
||||||
*/
|
|
||||||
func pathRelativeToRootForContainedInputFile(_ filePath: String) -> String {
|
|
||||||
guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else {
|
|
||||||
return filePath
|
|
||||||
}
|
|
||||||
return "\(path)/\(filePath)"
|
|
||||||
}
|
|
||||||
|
|
||||||
func backLinkText(for language: String) throws -> String? { nil }
|
|
||||||
|
|
||||||
/**
|
|
||||||
Returns the full path (relative to the site root for a page of the element in the given language.
|
|
||||||
*/
|
|
||||||
func localizedPath(for language: String) -> String {
|
|
||||||
path != "" ? "\(path)/\(language).html" : "\(language).html"
|
|
||||||
}
|
|
||||||
|
|
||||||
func relativePathToFileWithPath(_ filePath: String) -> String {
|
|
||||||
guard path != "" else {
|
|
||||||
return filePath
|
|
||||||
}
|
|
||||||
guard filePath.hasPrefix(path) else {
|
|
||||||
return filePath
|
|
||||||
}
|
|
||||||
return filePath.replacingOccurrences(of: path + "/", with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
private var additionalHeadContentUrl: URL {
|
|
||||||
inputFolder.appendingPathComponent("head.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasAdditionalHeadContent: Bool {
|
|
||||||
additionalHeadContentUrl.exists
|
|
||||||
}
|
|
||||||
|
|
||||||
func customHeadContent() throws -> String? {
|
|
||||||
let url = additionalHeadContentUrl
|
|
||||||
guard url.exists else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return try wrap(.failedToOpenFile(url.path)) {
|
|
||||||
try String(contentsOf: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var additionalFooterContentUrl: URL {
|
|
||||||
inputFolder.appendingPathComponent("footer.html")
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasAdditionalFooterContent: Bool {
|
|
||||||
additionalFooterContentUrl.exists
|
|
||||||
}
|
|
||||||
|
|
||||||
func customFooterContent() throws -> String? {
|
|
||||||
let url = additionalFooterContentUrl
|
|
||||||
guard url.exists else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return try wrap(.failedToOpenFile(url.path)) {
|
|
||||||
try String(contentsOf: url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SiteElement {
|
|
||||||
|
|
||||||
func printContents() {
|
|
||||||
print(path)
|
|
||||||
elements.forEach { $0.printContents() }
|
|
||||||
}
|
|
||||||
}
|
|
9
WebsiteGenerator/Extensions/Data+Extensions.swift
Normal file
9
WebsiteGenerator/Extensions/Data+Extensions.swift
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension Data {
|
||||||
|
|
||||||
|
func createFolderAndWrite(to url: URL) throws {
|
||||||
|
try url.ensureParentFolderExistence()
|
||||||
|
try write(to: url)
|
||||||
|
}
|
||||||
|
}
|
15
WebsiteGenerator/Extensions/NSImage+Extensions.swift
Normal file
15
WebsiteGenerator/Extensions/NSImage+Extensions.swift
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
extension NSImage {
|
||||||
|
|
||||||
|
func scaledDown(to size: NSSize) -> NSImage {
|
||||||
|
guard self.size.width > size.width else {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
return NSImage(size: size, flipped: false) { (resizedRect) -> Bool in
|
||||||
|
self.draw(in: resizedRect)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
28
WebsiteGenerator/Extensions/NSSize+Extensions.swift
Normal file
28
WebsiteGenerator/Extensions/NSSize+Extensions.swift
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension NSSize {
|
||||||
|
|
||||||
|
func scaledDown(to desiredWidth: CGFloat) -> NSSize {
|
||||||
|
if width == desiredWidth {
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
if width < desiredWidth {
|
||||||
|
// Don't scale larger
|
||||||
|
return self
|
||||||
|
}
|
||||||
|
|
||||||
|
let height = height * desiredWidth / width
|
||||||
|
return NSSize(width: desiredWidth, height: height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NSSize {
|
||||||
|
|
||||||
|
var ratio: CGFloat {
|
||||||
|
guard height != 0 else {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return width / height
|
||||||
|
}
|
||||||
|
}
|
@ -32,8 +32,16 @@ extension String {
|
|||||||
components(separatedBy: separator).last!
|
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 {
|
func insert(_ content: String, beforeLast separator: String) -> String {
|
||||||
let parts = components(separatedBy: separator)
|
let parts = components(separatedBy: separator)
|
||||||
|
guard parts.count > 1 else {
|
||||||
|
return self + content
|
||||||
|
}
|
||||||
return parts.dropLast().joined(separator: separator) + content + separator + parts.last!
|
return parts.dropLast().joined(separator: separator) + content + separator + parts.last!
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,3 +61,10 @@ extension Substring {
|
|||||||
.components(separatedBy: end).first!
|
.components(separatedBy: end).first!
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
|
||||||
|
func createFolderAndWrite(to url: URL) throws {
|
||||||
|
try data(using: .utf8)!.createFolderAndWrite(to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
38
WebsiteGenerator/Extensions/URL+Extensions.swift
Normal file
38
WebsiteGenerator/Extensions/URL+Extensions.swift
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension URL {
|
||||||
|
|
||||||
|
func ensureParentFolderExistence() throws {
|
||||||
|
try deletingLastPathComponent().ensureFolderExistence()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureFolderExistence() throws {
|
||||||
|
guard !exists else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDirectory: Bool {
|
||||||
|
(try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
|
||||||
|
}
|
||||||
|
|
||||||
|
var exists: Bool {
|
||||||
|
FileManager.default.fileExists(atPath: path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Delete the file at the url.
|
||||||
|
*/
|
||||||
|
func delete() throws {
|
||||||
|
try FileManager.default.removeItem(at: self)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copy(to url: URL) throws {
|
||||||
|
if url.exists {
|
||||||
|
try url.delete()
|
||||||
|
}
|
||||||
|
try url.ensureParentFolderExistence()
|
||||||
|
try FileManager.default.copyItem(at: self, to: url)
|
||||||
|
}
|
||||||
|
}
|
@ -1,306 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
#if canImport(AppKit)
|
|
||||||
import AppKit
|
|
||||||
#endif
|
|
||||||
|
|
||||||
final class FileProcessor {
|
|
||||||
|
|
||||||
enum MediaType {
|
|
||||||
case image
|
|
||||||
case video
|
|
||||||
case file
|
|
||||||
}
|
|
||||||
|
|
||||||
func mediaType(forExtension fileExtension: String) -> MediaType {
|
|
||||||
if supportedImageExtensions[fileExtension] != nil {
|
|
||||||
return .image
|
|
||||||
}
|
|
||||||
if supportedVideoExtensions.contains(fileExtension) {
|
|
||||||
return .video
|
|
||||||
}
|
|
||||||
return .file
|
|
||||||
}
|
|
||||||
|
|
||||||
private let supportedImageExtensions: [String : NSBitmapImageRep.FileType] = [
|
|
||||||
"jpg" : .jpeg,
|
|
||||||
"jpeg" : .jpeg,
|
|
||||||
"png" : .png,
|
|
||||||
]
|
|
||||||
|
|
||||||
private let supportedVideoExtensions: Set<String> = [
|
|
||||||
"mp4", "mov"
|
|
||||||
]
|
|
||||||
|
|
||||||
struct ImageOutput: Hashable {
|
|
||||||
|
|
||||||
let source: String
|
|
||||||
|
|
||||||
let width: Int
|
|
||||||
|
|
||||||
let desiredHeight: Int?
|
|
||||||
|
|
||||||
var ratio: Float? {
|
|
||||||
guard let desiredHeight = desiredHeight else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return Float(desiredHeight) / Float(width)
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasSimilarRatio(as other: ImageOutput) -> Bool {
|
|
||||||
guard let other = other.ratio, let ratio = ratio else {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return abs(other - ratio) < 0.1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let inputFolder: URL
|
|
||||||
|
|
||||||
let outputFolder: URL
|
|
||||||
|
|
||||||
/**
|
|
||||||
The files required by the site.
|
|
||||||
|
|
||||||
The content are the links to the files relative to the source root folder.
|
|
||||||
The files will be placed at the same path relative to the output folder
|
|
||||||
*/
|
|
||||||
private var requiredFiles: Set<String> = []
|
|
||||||
|
|
||||||
private var tasks: [String : ImageOutput] = [:]
|
|
||||||
|
|
||||||
init(inputFolder: URL, outputFolder: URL) {
|
|
||||||
self.inputFolder = inputFolder
|
|
||||||
self.outputFolder = outputFolder
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Files
|
|
||||||
|
|
||||||
/**
|
|
||||||
Add a file as required, so that it will be copied to the output directory.
|
|
||||||
*/
|
|
||||||
func require(file: String) {
|
|
||||||
requiredFiles.insert(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyRequiredFiles() throws {
|
|
||||||
var missingFiles = [String]()
|
|
||||||
for file in requiredFiles {
|
|
||||||
let sourceUrl = inputFolder.appendingPathComponent(file)
|
|
||||||
guard sourceUrl.exists else {
|
|
||||||
missingFiles.append(file)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
let destinationUrl = outputFolder.appendingPathComponent(file)
|
|
||||||
try FileSystem.copy(sourceUrl, to: destinationUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Images
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func requireImage(source: String, destination: String, width: Int, desiredHeight: Int? = nil, createDoubleVersion: Bool = false) throws -> NSSize {
|
|
||||||
let output = ImageOutput(
|
|
||||||
source: source,
|
|
||||||
width: width,
|
|
||||||
desiredHeight: desiredHeight)
|
|
||||||
|
|
||||||
return try requireImage(output,
|
|
||||||
for: destination,
|
|
||||||
createDoubleVersion: createDoubleVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func insert(_ image: ImageOutput, for destination: String) throws -> NSSize {
|
|
||||||
let sourceUrl = inputFolder.appendingPathComponent(image.source)
|
|
||||||
guard sourceUrl.exists else {
|
|
||||||
throw GenerationError.missingImage(sourceUrl.path)
|
|
||||||
}
|
|
||||||
guard let imageSize = NSImage(contentsOfFile: sourceUrl.path)?.size else {
|
|
||||||
let height = image.desiredHeight.unwrapped(CGFloat.init)
|
|
||||||
let width = CGFloat(image.width)
|
|
||||||
return .init(width: width, height: height ?? width / 16 * 9)
|
|
||||||
//throw GenerationError.failedToGenerateImage(sourceUrl.path)
|
|
||||||
}
|
|
||||||
let scaledSize = getScaledSize(of: imageSize, to: CGFloat(image.width))
|
|
||||||
|
|
||||||
guard let existing = tasks[destination] else {
|
|
||||||
//print("Image(\(image.width),\(image.desiredHeight ?? -1)) requested for \(destination)")
|
|
||||||
tasks[destination] = image
|
|
||||||
return scaledSize
|
|
||||||
}
|
|
||||||
guard existing.source == image.source else {
|
|
||||||
throw GenerationError.conflictingImageSources(
|
|
||||||
output: destination, in1: existing.source, in2: image.source)
|
|
||||||
}
|
|
||||||
guard existing.hasSimilarRatio(as: image) else {
|
|
||||||
throw GenerationError.conflictingImageRatios(
|
|
||||||
output: destination, in1: existing.source, in2: image.source)
|
|
||||||
}
|
|
||||||
if image.width > existing.width {
|
|
||||||
//print("Image(\(image.width),\(image.desiredHeight ?? -1)) requested for \(destination)")
|
|
||||||
tasks[destination] = image
|
|
||||||
}
|
|
||||||
return scaledSize
|
|
||||||
}
|
|
||||||
|
|
||||||
@discardableResult
|
|
||||||
func requireImage(_ image: ImageOutput, for destination: String, createDoubleVersion: Bool = false) throws -> NSSize {
|
|
||||||
let size = try insert(image, for: destination)
|
|
||||||
guard createDoubleVersion else {
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = try requireImage(
|
|
||||||
source: image.source,
|
|
||||||
destination: destination.insert("@2x", beforeLast: "."),
|
|
||||||
width: image.width * 2,
|
|
||||||
desiredHeight: image.desiredHeight.unwrapped { $0 * 2 } )
|
|
||||||
// Return 1x size
|
|
||||||
return size
|
|
||||||
}
|
|
||||||
|
|
||||||
func createImages() throws {
|
|
||||||
for (destination, image) in tasks {
|
|
||||||
try createImageIfNeeded(image, for: destination)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func createImageIfNeeded(_ image: ImageOutput, for destination: String) throws {
|
|
||||||
let source = inputFolder.appendingPathComponent(image.source)
|
|
||||||
guard source.exists else {
|
|
||||||
throw GenerationError.missingImage(source.path)
|
|
||||||
}
|
|
||||||
let destination = outputFolder.appendingPathComponent(destination)
|
|
||||||
#warning("Check if source image has changed since last run")
|
|
||||||
guard !destination.exists else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure that image file is supported
|
|
||||||
let ext = destination.pathExtension.lowercased()
|
|
||||||
guard supportedImageExtensions[ext] != nil else {
|
|
||||||
print("Copying file \(source.path)")
|
|
||||||
try FileSystem.copy(source, to: destination)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
#if canImport(AppKit)
|
|
||||||
try createImage(
|
|
||||||
destination,
|
|
||||||
from: source,
|
|
||||||
with: CGFloat(image.width),
|
|
||||||
and: image.desiredHeight.unwrapped(CGFloat.init))
|
|
||||||
#else
|
|
||||||
throw GenerationError.failedToGenerateImage(destination.path)
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
#if canImport(AppKit)
|
|
||||||
private func createImage(_ destination: URL, from source: URL, with desiredWidth: CGFloat, and desiredHeight: CGFloat?) throws {
|
|
||||||
|
|
||||||
guard let sourceImage = NSImage(contentsOfFile: source.path) else {
|
|
||||||
print("Failed to load image \(source.path)")
|
|
||||||
throw GenerationError.failedToGenerateImage(source.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
let destinationSize = getScaledSize(of: sourceImage.size, to: desiredWidth)
|
|
||||||
let scaledImage = scale(image: sourceImage, to: destinationSize)
|
|
||||||
let scaledSize = scaledImage.size
|
|
||||||
|
|
||||||
if abs(scaledImage.size.width - desiredWidth) > 2 {
|
|
||||||
print("[WARN] Image \(destination.path) scaled incorrectly (wanted width \(desiredWidth), is \(scaledSize.width))")
|
|
||||||
}
|
|
||||||
if abs(destinationSize.height - scaledImage.size.height) > 2 {
|
|
||||||
print("[WARN] Image \(destination.path) scaled incorrectly (wanted height \(destinationSize.height), is \(scaledSize.height))")
|
|
||||||
}
|
|
||||||
if let desiredHeight = desiredHeight {
|
|
||||||
let desiredRatio = desiredHeight / desiredWidth
|
|
||||||
let adjustedDesiredHeight = scaledSize.width * desiredRatio
|
|
||||||
if abs(adjustedDesiredHeight - scaledSize.height) > 5 {
|
|
||||||
print("[WARN] Image \(source.path): Desired height \(adjustedDesiredHeight) (actually \(desiredHeight)), got \(scaledSize.height) after reduction")
|
|
||||||
throw GenerationError.imageRatioMismatch(destination.path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if scaledSize.width > desiredWidth {
|
|
||||||
print("[WARN] Image \(source.path) is too large (expected width \(desiredWidth), got \(scaledSize.width)")
|
|
||||||
}
|
|
||||||
|
|
||||||
try saveImage(scaledImage, atUrl: destination)
|
|
||||||
|
|
||||||
guard let savedImage = NSImage(contentsOfFile: destination.path) else {
|
|
||||||
throw GenerationError.failedToGenerateImage(source.path)
|
|
||||||
}
|
|
||||||
let savedSize = savedImage.size
|
|
||||||
if destination.lastPathComponent.hasSuffix("@2x.jpg") {
|
|
||||||
if abs(savedSize.height - destinationSize.height/2) > 2 || abs(savedSize.width - destinationSize.width/2) > 2 {
|
|
||||||
print("[WARN] Image \(destination.path) (2x): Expected (\(destinationSize.width/2),\(destinationSize.height/2)), got (\(savedSize.width),\(savedSize.height))")
|
|
||||||
}
|
|
||||||
} else if abs(savedSize.height - destinationSize.height) > 2 || abs(savedSize.width - destinationSize.width) > 2 {
|
|
||||||
print("[WARN] Image \(destination.path): Expected (\(destinationSize.width),\(destinationSize.height)), got (\(savedSize.width),\(savedSize.height))")
|
|
||||||
}
|
|
||||||
// print("Source (\(sourceWidth),\(sourceHeight))")
|
|
||||||
// print("Desired (\(desiredWidth),\(desiredHeight!))")
|
|
||||||
// print("Expected (\(expectedScaledWidth),\(expectedScaledHeight))")
|
|
||||||
// print("Scaled (\(scaledWidth),\(scaledImage.size.height))")
|
|
||||||
// print("Saved (\(savedWidth),\(savedHeight))")
|
|
||||||
// print(NSScreen.main!.backingScaleFactor)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveImage(_ image: NSImage, atUrl url: URL) throws {
|
|
||||||
let ext = url.pathExtension.lowercased()
|
|
||||||
guard let type = supportedImageExtensions[ext] else {
|
|
||||||
print("No image type for \(url.path)")
|
|
||||||
throw GenerationError.failedToGenerateImage(url.path)
|
|
||||||
}
|
|
||||||
guard let tiff = image.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else {
|
|
||||||
print("Failed to get data for image \(url.path)")
|
|
||||||
throw GenerationError.failedToGenerateImage(url.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let data = tiffData.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
|
|
||||||
print("Failed to get data for image \(url.path)")
|
|
||||||
throw GenerationError.failedToGenerateImage(url.path)
|
|
||||||
}
|
|
||||||
try data.createFolderAndWrite(to: url)
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private extension Int {
|
|
||||||
|
|
||||||
func multiply(by factor: Int) -> Int {
|
|
||||||
self * factor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getScaledSize(of source: NSSize, to desiredWidth: CGFloat) -> NSSize {
|
|
||||||
if source.width == desiredWidth {
|
|
||||||
return source
|
|
||||||
}
|
|
||||||
|
|
||||||
if source.width < desiredWidth {
|
|
||||||
// Keep existing image if image is too small already
|
|
||||||
return source
|
|
||||||
//print("Image \(destination.path) too small (wanted width \(desiredWidth), has only \(sourceWidth))")
|
|
||||||
}
|
|
||||||
|
|
||||||
let height = source.height * desiredWidth / source.width
|
|
||||||
return NSSize(width: desiredWidth, height: height)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func scale(image: NSImage, to size: NSSize) -> NSImage {
|
|
||||||
guard image.size.width > size.width else {
|
|
||||||
return image
|
|
||||||
}
|
|
||||||
//resize image
|
|
||||||
return NSImage(size: size, flipped: false) { (resizedRect) -> Bool in
|
|
||||||
image.draw(in: resizedRect)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private extension NSSize {
|
|
||||||
|
|
||||||
var ratio: CGFloat {
|
|
||||||
width / height
|
|
||||||
}
|
|
||||||
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
378
WebsiteGenerator/Files/FileSystem.swift
Normal file
378
WebsiteGenerator/Files/FileSystem.swift
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
typealias SourceFile = (data: Data, didChange: Bool)
|
||||||
|
typealias SourceTextFile = (content: String, didChange: Bool)
|
||||||
|
|
||||||
|
final class FileSystem {
|
||||||
|
|
||||||
|
private static let hashesFileName = "hashes.json"
|
||||||
|
|
||||||
|
private let input: URL
|
||||||
|
|
||||||
|
private let output: URL
|
||||||
|
|
||||||
|
private let source = "FileChangeMonitor"
|
||||||
|
|
||||||
|
private var hashesFile: URL {
|
||||||
|
input.appendingPathComponent(FileSystem.hashesFileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
The hashes of all accessed files from the previous run
|
||||||
|
|
||||||
|
The key is the relative path to the file from the source
|
||||||
|
*/
|
||||||
|
private var previousFiles: [String : Data] = [:]
|
||||||
|
|
||||||
|
/**
|
||||||
|
The paths of all files which were accessed, with their new hashes
|
||||||
|
|
||||||
|
This list is used to check if a file was modified, and to write all accessed files back to disk
|
||||||
|
*/
|
||||||
|
private var accessedFiles: [String : Data] = [:]
|
||||||
|
|
||||||
|
/**
|
||||||
|
All files which should be copied to the output folder
|
||||||
|
*/
|
||||||
|
private var requiredFiles: Set<String> = []
|
||||||
|
|
||||||
|
/**
|
||||||
|
The image creation tasks.
|
||||||
|
|
||||||
|
The key is the destination path.
|
||||||
|
*/
|
||||||
|
private var imageTasks: [String : ImageOutput] = [:]
|
||||||
|
|
||||||
|
init(in input: URL, to output: URL) {
|
||||||
|
self.input = input
|
||||||
|
self.output = output
|
||||||
|
|
||||||
|
guard exists(hashesFile) else {
|
||||||
|
log.add(info: "No file hashes loaded, regarding all content as new", source: source)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: hashesFile)
|
||||||
|
} catch {
|
||||||
|
log.add(
|
||||||
|
warning: "File hashes could not be read, regarding all content as new",
|
||||||
|
source: source,
|
||||||
|
error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
self.previousFiles = try JSONDecoder().decode(from: data)
|
||||||
|
} catch {
|
||||||
|
log.add(
|
||||||
|
warning: "File hashes could not be decoded, regarding all content as new",
|
||||||
|
source: source,
|
||||||
|
error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlInOutputFolder(_ path: String) -> URL {
|
||||||
|
output.appendingPathComponent(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func urlInContentFolder(_ path: String) -> URL {
|
||||||
|
input.appendingPathComponent(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
Get the current hash of file data at a path.
|
||||||
|
|
||||||
|
If the hash has been computed previously during the current run, then this function directly returns it.
|
||||||
|
*/
|
||||||
|
private func hash(_ data: Data, at path: String) -> Data {
|
||||||
|
accessedFiles[path] ?? SHA256.hash(data: data).data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func exists(_ url: URL) -> Bool {
|
||||||
|
FileManager.default.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataOfRequiredFile(atPath path: String, source: String) -> Data? {
|
||||||
|
let url = input.appendingPathComponent(path)
|
||||||
|
guard exists(url) else {
|
||||||
|
log.failedToOpen(path, requiredBy: source, error: nil)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
log.failedToOpen(path, requiredBy: source, error: error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dataOfOptionalFile(atPath path: String, source: String) -> Data? {
|
||||||
|
let url = input.appendingPathComponent(path)
|
||||||
|
guard exists(url) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
log.failedToOpen(path, requiredBy: source, error: error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contentOfOptionalFile(atPath path: String, source: String) -> String? {
|
||||||
|
let url = input.appendingPathComponent(path)
|
||||||
|
guard exists(url) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
return try String(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
log.failedToOpen(path, requiredBy: source, error: error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func getData(atPath path: String) -> SourceFile? {
|
||||||
|
let url = input.appendingPathComponent(path)
|
||||||
|
guard exists(url) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: url)
|
||||||
|
} catch {
|
||||||
|
log.add(error: "Failed to read data at \(path)", source: source, error: error)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let newHash = hash(data, at: path)
|
||||||
|
defer {
|
||||||
|
accessedFiles[path] = newHash
|
||||||
|
}
|
||||||
|
guard let oldHash = previousFiles[path] else {
|
||||||
|
return (data: data, didChange: true)
|
||||||
|
}
|
||||||
|
return (data: data, didChange: oldHash != newHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHashes() {
|
||||||
|
do {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = .prettyPrinted
|
||||||
|
let data = try encoder.encode(accessedFiles)
|
||||||
|
try data.write(to: hashesFile)
|
||||||
|
} catch {
|
||||||
|
log.add(warning: "Failed to save file hashes", source: source, error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Images
|
||||||
|
|
||||||
|
private func loadImage(atPath path: String) -> (image: NSImage, changed: Bool)? {
|
||||||
|
guard let (data, changed) = getData(atPath: path) else {
|
||||||
|
print("Failed to load image data \(path)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let image = NSImage(data: data) else {
|
||||||
|
print("Failed to read image \(path)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (image, changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func requireImage(source: String, destination: String, width: Int, desiredHeight: Int? = nil, createDoubleVersion: Bool = false) -> NSSize {
|
||||||
|
let height = desiredHeight.unwrapped(CGFloat.init)
|
||||||
|
let sourceUrl = input.appendingPathComponent(source)
|
||||||
|
let image = ImageOutput(source: source, width: width, desiredHeight: desiredHeight)
|
||||||
|
|
||||||
|
let standardSize = NSSize(width: CGFloat(width), height: height ?? CGFloat(width) / 16 * 9)
|
||||||
|
guard sourceUrl.exists else {
|
||||||
|
log.add(error: "Missing image \(source) with size (\(width),\(desiredHeight ?? -1)",
|
||||||
|
source: "Image Processor")
|
||||||
|
return standardSize
|
||||||
|
}
|
||||||
|
guard let imageSize = loadImage(atPath: image.source)?.image.size else {
|
||||||
|
log.add(error: "Unreadable image \(source) with size (\(width),\(desiredHeight ?? -1)",
|
||||||
|
source: "Image Processor")
|
||||||
|
return standardSize
|
||||||
|
}
|
||||||
|
let scaledSize = imageSize.scaledDown(to: CGFloat(width))
|
||||||
|
|
||||||
|
guard let existing = imageTasks[destination] else {
|
||||||
|
//print("Image(\(image.width),\(image.desiredHeight ?? -1)) requested for \(destination)")
|
||||||
|
imageTasks[destination] = image
|
||||||
|
return scaledSize
|
||||||
|
}
|
||||||
|
guard existing.source == source else {
|
||||||
|
log.add(error: "Multiple sources (\(existing.source),\(source)) for image \(destination)",
|
||||||
|
source: "Image Processor")
|
||||||
|
return scaledSize
|
||||||
|
}
|
||||||
|
guard existing.hasSimilarRatio(as: image) else {
|
||||||
|
log.add(error: "Multiple ratios (\(existing.ratio!),\(image.ratio!)) for image \(destination)",
|
||||||
|
source: "Image Processor")
|
||||||
|
return scaledSize
|
||||||
|
}
|
||||||
|
if image.width > existing.width {
|
||||||
|
log.add(info: "Increasing size of image \(destination) from \(existing.width) to \(width)",
|
||||||
|
source: "Image Processor")
|
||||||
|
imageTasks[destination] = image
|
||||||
|
}
|
||||||
|
return scaledSize
|
||||||
|
}
|
||||||
|
|
||||||
|
#warning("Implement image functions")
|
||||||
|
func createImages() {
|
||||||
|
for (destination, image) in imageTasks.sorted(by: { $0.key < $1.key }) {
|
||||||
|
createImageIfNeeded(image, for: destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createImageIfNeeded(_ image: ImageOutput, for destination: String) {
|
||||||
|
guard let (sourceImageData, sourceImageChanged) = getData(atPath: image.source) else {
|
||||||
|
log.add(error: "Failed to open image \(image.source)", source: "Image Processor")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationUrl = output.appendingPathComponent(destination)
|
||||||
|
|
||||||
|
// Check if image needs to be updated
|
||||||
|
guard !destinationUrl.exists || sourceImageChanged else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure that image file is supported
|
||||||
|
let ext = destinationUrl.pathExtension.lowercased()
|
||||||
|
guard MediaType.isProcessableImage(ext) else {
|
||||||
|
log.add(info: "Copying image \(image.source)", source: "Image Processor")
|
||||||
|
do {
|
||||||
|
let sourceUrl = input.appendingPathComponent(image.source)
|
||||||
|
try destinationUrl.ensureParentFolderExistence()
|
||||||
|
try sourceUrl.copy(to: destinationUrl)
|
||||||
|
} catch {
|
||||||
|
log.add(error: "Failed to copy image \(image.source) to \(destination)", source: "Image Processor")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let sourceImage = NSImage(data: sourceImageData) else {
|
||||||
|
print("Failed to read image \(image.source)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let desiredWidth = CGFloat(image.width)
|
||||||
|
let desiredHeight = image.desiredHeight.unwrapped(CGFloat.init)
|
||||||
|
|
||||||
|
let destinationSize = sourceImage.size.scaledDown(to: desiredWidth)
|
||||||
|
let scaledImage = sourceImage.scaledDown(to: destinationSize)
|
||||||
|
let scaledSize = scaledImage.size
|
||||||
|
|
||||||
|
if abs(scaledImage.size.width - desiredWidth) > 2 {
|
||||||
|
print("[WARN] Image \(destination) scaled incorrectly (wanted width \(desiredWidth), is \(scaledSize.width))")
|
||||||
|
}
|
||||||
|
if abs(destinationSize.height - scaledImage.size.height) > 2 {
|
||||||
|
print("[WARN] Image \(destination) scaled incorrectly (wanted height \(destinationSize.height), is \(scaledSize.height))")
|
||||||
|
}
|
||||||
|
if let desiredHeight = desiredHeight {
|
||||||
|
let desiredRatio = desiredHeight / desiredWidth
|
||||||
|
let adjustedDesiredHeight = scaledSize.width * desiredRatio
|
||||||
|
if abs(adjustedDesiredHeight - scaledSize.height) > 5 {
|
||||||
|
log.add(warning: "Image \(image.source): Desired height \(adjustedDesiredHeight) (actually \(desiredHeight)), got \(scaledSize.height) after reduction", source: "Image Processor")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scaledSize.width > desiredWidth {
|
||||||
|
print("[WARN] Image \(image.source) is too large (expected width \(desiredWidth), got \(scaledSize.width)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let destinationExtension = destinationUrl.pathExtension.lowercased()
|
||||||
|
guard let type = MediaType.supportedImage(destinationExtension) else {
|
||||||
|
log.add(error: "No image type for \(destination) with extension \(destinationExtension)",
|
||||||
|
source: "Image Processor")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let tiff = scaledImage.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else {
|
||||||
|
log.add(error: "Failed to get data for image \(image.source)", source: "Image Processor")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let data = tiffData.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
|
||||||
|
log.add(error: "Failed to get data for image \(image.source)", source: "Image Processor")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try data.createFolderAndWrite(to: destinationUrl)
|
||||||
|
} catch {
|
||||||
|
log.add(error: "Failed to write image \(destination)", source: "Image Processor", error: error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: File copying
|
||||||
|
|
||||||
|
/**
|
||||||
|
Add a file as required, so that it will be copied to the output directory.
|
||||||
|
*/
|
||||||
|
func require(file: String) {
|
||||||
|
requiredFiles.insert(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyRequiredFiles() {
|
||||||
|
var missingFiles = [String]()
|
||||||
|
for file in requiredFiles {
|
||||||
|
let sourceUrl = input.appendingPathComponent(file)
|
||||||
|
guard sourceUrl.exists else {
|
||||||
|
missingFiles.append(file)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let data: Data
|
||||||
|
do {
|
||||||
|
data = try Data(contentsOf: sourceUrl)
|
||||||
|
} catch {
|
||||||
|
log.add(error: "Failed to read data at \(sourceUrl.path)", source: source, error: error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let destinationUrl = output.appendingPathComponent(file)
|
||||||
|
write(data, to: destinationUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Writing files
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func write(_ data: Data, to url: URL) -> Bool {
|
||||||
|
// Only write changed files
|
||||||
|
if url.exists, let oldContent = try? Data(contentsOf: url), data == oldContent {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try data.createFolderAndWrite(to: url)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
log.add(error: "Failed to write file", source: url.path, error: error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func write(_ string: String, to url: URL) -> Bool {
|
||||||
|
let data = string.data(using: .utf8)!
|
||||||
|
return write(data, to: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension Digest {
|
||||||
|
|
||||||
|
var bytes: [UInt8] { Array(makeIterator()) }
|
||||||
|
|
||||||
|
var data: Data { Data(bytes) }
|
||||||
|
|
||||||
|
var hexStr: String {
|
||||||
|
bytes.map { String(format: "%02X", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
24
WebsiteGenerator/Files/ImageOutput.swift
Normal file
24
WebsiteGenerator/Files/ImageOutput.swift
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ImageOutput: Hashable {
|
||||||
|
|
||||||
|
let source: String
|
||||||
|
|
||||||
|
let width: Int
|
||||||
|
|
||||||
|
let desiredHeight: Int?
|
||||||
|
|
||||||
|
var ratio: Float? {
|
||||||
|
guard let desiredHeight = desiredHeight else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Float(desiredHeight) / Float(width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasSimilarRatio(as other: ImageOutput) -> Bool {
|
||||||
|
guard let other = other.ratio, let ratio = ratio else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return abs(other - ratio) < 0.1
|
||||||
|
}
|
||||||
|
}
|
36
WebsiteGenerator/Files/MediaType.swift
Normal file
36
WebsiteGenerator/Files/MediaType.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
private let supportedImageExtensions: [String : NSBitmapImageRep.FileType] = [
|
||||||
|
"jpg" : .jpeg,
|
||||||
|
"jpeg" : .jpeg,
|
||||||
|
"png" : .png,
|
||||||
|
]
|
||||||
|
|
||||||
|
private let supportedVideoExtensions: Set<String> = [
|
||||||
|
"mp4", "mov"
|
||||||
|
]
|
||||||
|
|
||||||
|
enum MediaType {
|
||||||
|
case image
|
||||||
|
case video
|
||||||
|
case file
|
||||||
|
|
||||||
|
init(fileExtension: String) {
|
||||||
|
if supportedImageExtensions[fileExtension] != nil {
|
||||||
|
self = .image
|
||||||
|
} else if supportedVideoExtensions.contains(fileExtension) {
|
||||||
|
self = .video
|
||||||
|
} else {
|
||||||
|
self = .file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isProcessableImage(_ fileExtension: String) -> Bool {
|
||||||
|
supportedImage(fileExtension) != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func supportedImage(_ fileExtension: String) -> NSBitmapImageRep.FileType? {
|
||||||
|
supportedImageExtensions[fileExtension]
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class ErrorOutput {
|
final class ValidationLog {
|
||||||
|
|
||||||
private enum LogLevel: String {
|
private enum LogLevel: String {
|
||||||
case error = "ERROR"
|
case error = "ERROR"
|
||||||
@ -138,7 +138,7 @@ final class ErrorOutput {
|
|||||||
guard let string = string else {
|
guard let string = string else {
|
||||||
return nil
|
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)
|
add(warning: "Invalid date string '\(string)' for property '\(property)'", source: source)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
@ -1,51 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum GenerationError: Error {
|
|
||||||
case missingMarkerInTemplate(String)
|
|
||||||
case failedToLoadTemplate(String)
|
|
||||||
case failedToWriteFile(String)
|
|
||||||
case failedToOpenFile(String)
|
|
||||||
case failedToDecodeJSON(file: String, error: String)
|
|
||||||
case invalidLanguageSpecification(String)
|
|
||||||
case invalidDateInPageMetadata(String)
|
|
||||||
case failedToEncodeJSON(String)
|
|
||||||
case missingSectionMetadata(section: String, language: String)
|
|
||||||
case missingPageMetadata(page: String, language: String)
|
|
||||||
case missingPage(page: String, language: String)
|
|
||||||
case missingImage(String)
|
|
||||||
case conflictingImageSources(output: String, in1: String, in2: String)
|
|
||||||
case conflictingImageRatios(output: String, in1: String, in2: String)
|
|
||||||
case failedToGenerateImage(String)
|
|
||||||
case imageRatioMismatch(String)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrap<T>(_ error: GenerationError, execute: () throws -> T) rethrows -> T {
|
|
||||||
do {
|
|
||||||
return try execute()
|
|
||||||
} catch let underlyingError {
|
|
||||||
print(underlyingError)
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func wrap<T>(_ error: (Error) -> GenerationError, execute: () throws -> T) rethrows -> T {
|
|
||||||
do {
|
|
||||||
return try execute()
|
|
||||||
} catch let underlyingError {
|
|
||||||
print(underlyingError)
|
|
||||||
throw error(underlyingError)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension Optional {
|
|
||||||
|
|
||||||
func unwrap(or error: GenerationError) throws -> Wrapped {
|
|
||||||
switch self {
|
|
||||||
case .none:
|
|
||||||
throw error
|
|
||||||
case .some(let wrapped):
|
|
||||||
return wrapped
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,21 +1,5 @@
|
|||||||
import Foundation
|
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 {
|
struct IndexPageGenerator {
|
||||||
|
|
||||||
private let factory: LocalizedSiteTemplate
|
private let factory: LocalizedSiteTemplate
|
||||||
@ -25,41 +9,34 @@ struct IndexPageGenerator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func generate(
|
func generate(
|
||||||
site: Site,
|
site: Element,
|
||||||
language: String,
|
language: String,
|
||||||
languageButton: String?,
|
languageButton: String?,
|
||||||
sectionItemCount: Int,
|
sectionItemCount: Int,
|
||||||
to url: URL) throws {
|
to url: URL) {
|
||||||
let localized = site.localized(for: language)
|
let localized = site.localized(for: language)
|
||||||
|
|
||||||
var content = [PageTemplate.Key : String]()
|
var content = [PageTemplate.Key : String]()
|
||||||
content[.head] = try makeHead(site: site, language: language)
|
content[.head] = factory.pageHead.generate(page: site, language: language)
|
||||||
content[.topBar] = factory.topBar.generate(section: nil, languageButton: languageButton)
|
content[.topBar] = factory.topBar.generate(sectionUrl: nil, languageButton: languageButton)
|
||||||
content[.contentClass] = "overview"
|
content[.contentClass] = "overview"
|
||||||
content[.header] = makeHeader(localized: localized)
|
content[.header] = makeHeader(localized: localized)
|
||||||
let sections = site.elements.compactMap { $0 as? Section }
|
content[.content] = factory.overviewSection.generate(
|
||||||
content[.content] = try factory.overviewSection.generate(
|
sections: site.elements,
|
||||||
sections: sections,
|
|
||||||
in: site,
|
in: site,
|
||||||
language: language,
|
language: language,
|
||||||
sectionItemCount: sectionItemCount)
|
sectionItemCount: sectionItemCount)
|
||||||
content[.footer] = try site.customFooterContent()
|
content[.footer] = site.customFooterContent()
|
||||||
try factory.page.generate(content, to: url)
|
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 {
|
private func makeHeader(localized: Element.LocalizedMetadata) -> 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 {
|
|
||||||
var content = [HeaderKey : String]()
|
var content = [HeaderKey : String]()
|
||||||
content[.title] = localized.title
|
content[.title] = localized.title
|
||||||
|
#warning("Add title suffix")
|
||||||
content[.subtitle] = localized.subtitle
|
content[.subtitle] = localized.subtitle
|
||||||
content[.titleText] = localized.description
|
content[.titleText] = localized.description
|
||||||
return factory.factory.centeredHeader.generate(content)
|
return factory.factory.centeredHeader.generate(content)
|
||||||
|
@ -9,31 +9,18 @@ struct PageContentGenerator {
|
|||||||
|
|
||||||
private let factory: TemplateFactory
|
private let factory: TemplateFactory
|
||||||
|
|
||||||
private let files: FileProcessor
|
|
||||||
|
|
||||||
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
|
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
|
||||||
|
|
||||||
init(factory: TemplateFactory, files: FileProcessor) {
|
init(factory: TemplateFactory) {
|
||||||
self.factory = factory
|
self.factory = factory
|
||||||
self.files = files
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate(page: Page, language: String, at url: URL) throws -> String {
|
func generate(page: Element, language: String, content: String) -> String {
|
||||||
var errorToThrow: Error? = nil
|
|
||||||
|
|
||||||
let content = try wrap(.missingPage(page: url.path, language: language)) {
|
|
||||||
try String(contentsOf: url)
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasCodeContent = false
|
var hasCodeContent = false
|
||||||
|
|
||||||
let imageModifier = Modifier(target: .images) { html, markdown in
|
let imageModifier = Modifier(target: .images) { html, markdown in
|
||||||
do {
|
processMarkdownImage(markdown: markdown, html: html, page: page)
|
||||||
return try processMarkdownImage(markdown: markdown, html: html, page: page)
|
|
||||||
} catch {
|
|
||||||
errorToThrow = error
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
|
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
|
||||||
if markdown.starts(with: "```swift") {
|
if markdown.starts(with: "```swift") {
|
||||||
@ -51,17 +38,13 @@ struct PageContentGenerator {
|
|||||||
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier])
|
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier])
|
||||||
|
|
||||||
if hasCodeContent {
|
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)
|
return parser.html(from: content)
|
||||||
if let error = errorToThrow {
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
// Split the markdown ![alt](file "title")
|
||||||
// For images: ![left_title](file "right_title")
|
// For images: ![left_title](file "right_title")
|
||||||
// For videos: ![option...](file)
|
// For videos: ![option...](file)
|
||||||
@ -72,27 +55,27 @@ struct PageContentGenerator {
|
|||||||
let alt = markdown.between("[", and: "]").nonEmpty
|
let alt = markdown.between("[", and: "]").nonEmpty
|
||||||
|
|
||||||
let fileExtension = file.lastComponentAfter(".").lowercased()
|
let fileExtension = file.lastComponentAfter(".").lowercased()
|
||||||
switch files.mediaType(forExtension: fileExtension) {
|
switch MediaType(fileExtension: fileExtension) {
|
||||||
case .image:
|
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:
|
case .video:
|
||||||
return try handleVideo(page: page, file: file, optionString: alt)
|
return handleVideo(page: page, file: file, optionString: alt)
|
||||||
case .file:
|
case .file:
|
||||||
if fileExtension == "svg" {
|
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 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 imagePath2x = imagePath.insert("@2x", beforeLast: ".")
|
||||||
let file2x = file.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] = [
|
let content: [PageImageTemplate.Key : String] = [
|
||||||
.image: file,
|
.image: file,
|
||||||
@ -104,7 +87,7 @@ struct PageContentGenerator {
|
|||||||
return factory.image.generate(content)
|
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
|
let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in
|
||||||
string.components(separatedBy: " ").compactMap { optionText in
|
string.components(separatedBy: " ").compactMap { optionText in
|
||||||
guard let optionText = optionText.trimmed.nonEmpty else {
|
guard let optionText = optionText.trimmed.nonEmpty else {
|
||||||
@ -125,7 +108,7 @@ struct PageContentGenerator {
|
|||||||
return factory.video.generate(sources: sources, options: options)
|
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)
|
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||||
files.require(file: imagePath)
|
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")
|
#warning("Handle other files in markdown")
|
||||||
print("[WARN] Unhandled file \(file) with extension \(fileExtension)")
|
print("[WARN] Unhandled file \(file) with extension \(fileExtension)")
|
||||||
return ""
|
return ""
|
||||||
|
@ -4,65 +4,53 @@ struct OverviewPageGenerator {
|
|||||||
|
|
||||||
private let factory: LocalizedSiteTemplate
|
private let factory: LocalizedSiteTemplate
|
||||||
|
|
||||||
let outputFolder: URL
|
init(factory: LocalizedSiteTemplate) {
|
||||||
|
|
||||||
init(factory: LocalizedSiteTemplate, files: FileProcessor) {
|
|
||||||
self.factory = factory
|
self.factory = factory
|
||||||
self.outputFolder = files.outputFolder
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate(
|
func generate(
|
||||||
section: Section,
|
section: Element,
|
||||||
language: String,
|
language: String,
|
||||||
backText: String?) throws {
|
backText: String?) {
|
||||||
let url = outputFolder.appendingPathComponent(section.localizedPath(for: language))
|
let path = section.localizedPath(for: language)
|
||||||
|
let url = files.urlInOutputFolder(path)
|
||||||
|
|
||||||
let metadata = section.localized(for: language)
|
let metadata = section.localized(for: language)
|
||||||
|
|
||||||
var content = [PageTemplate.Key : String]()
|
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)
|
let languageButton = section.nextLanguage(for: language)
|
||||||
content[.topBar] = factory.topBar.generate(
|
content[.topBar] = factory.topBar.generate(
|
||||||
section: section.sectionId,
|
sectionUrl: section.sectionUrl(for: language),
|
||||||
languageButton: languageButton)
|
languageButton: languageButton)
|
||||||
content[.contentClass] = "overview"
|
content[.contentClass] = "overview"
|
||||||
content[.header] = makeHeader(metadata: metadata, language: language, backText: backText)
|
content[.header] = makeHeader(metadata: metadata, language: language, backText: backText)
|
||||||
content[.content] = try makeContent(section: section, language: language)
|
content[.content] = makeContent(section: section, language: language)
|
||||||
content[.footer] = try section.customFooterContent()
|
content[.footer] = section.customFooterContent()
|
||||||
try factory.page.generate(content, to: url)
|
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 {
|
if section.hasNestingElements {
|
||||||
let sections = section.elements.compactMap { $0 as? Section }
|
return factory.overviewSection.generate(
|
||||||
return try factory.overviewSection.generate(
|
sections: section.elements,
|
||||||
sections: sections,
|
|
||||||
in: section,
|
in: section,
|
||||||
language: language,
|
language: language,
|
||||||
sectionItemCount: section.metadata.sectionOverviewItemCount)
|
sectionItemCount: section.overviewItemCount)
|
||||||
} else {
|
} 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 {
|
private func makeHeader(metadata: Element.LocalizedMetadata,
|
||||||
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,
|
|
||||||
language: String,
|
language: String,
|
||||||
backText: String?) -> String {
|
backText: String?) -> String {
|
||||||
var content = [HeaderKey : String]()
|
var content = [HeaderKey : String]()
|
||||||
content[.title] = metadata.title
|
content[.title] = metadata.title
|
||||||
|
#warning("Add title suffix")
|
||||||
content[.subtitle] = metadata.subtitle
|
content[.subtitle] = metadata.subtitle
|
||||||
content[.titleText] = metadata.description
|
content[.titleText] = metadata.description
|
||||||
content[.backLink] = backText.unwrapped { factory.makeBackLink(text: $0, language: language) }
|
content[.backLink] = backText.unwrapped { factory.makeBackLink(text: $0, language: language) }
|
||||||
|
@ -6,19 +6,16 @@ struct OverviewSectionGenerator {
|
|||||||
|
|
||||||
private let singleSectionsTemplate: OverviewSectionCleanTemplate
|
private let singleSectionsTemplate: OverviewSectionCleanTemplate
|
||||||
|
|
||||||
let files: FileProcessor
|
|
||||||
|
|
||||||
private let generator: ThumbnailListGenerator
|
private let generator: ThumbnailListGenerator
|
||||||
|
|
||||||
init(factory: TemplateFactory, files: FileProcessor) {
|
init(factory: TemplateFactory) {
|
||||||
self.multipleSectionsTemplate = factory.overviewSection
|
self.multipleSectionsTemplate = factory.overviewSection
|
||||||
self.singleSectionsTemplate = factory.overviewSectionClean
|
self.singleSectionsTemplate = factory.overviewSectionClean
|
||||||
self.files = files
|
self.generator = ThumbnailListGenerator(factory: factory)
|
||||||
self.generator = ThumbnailListGenerator(factory: factory, files: files)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate(sections: [Section], in parent: SiteElement, language: String, sectionItemCount: Int) throws -> String {
|
func generate(sections: [Element], in parent: Element, language: String, sectionItemCount: Int) -> String {
|
||||||
try sections.map { section in
|
sections.map { section in
|
||||||
let metadata = section.localized(for: language)
|
let metadata = section.localized(for: language)
|
||||||
let fullUrl = section.fullPageUrl(for: language)
|
let fullUrl = section.fullPageUrl(for: language)
|
||||||
let relativeUrl = parent.relativePathToFileWithPath(fullUrl)
|
let relativeUrl = parent.relativePathToFileWithPath(fullUrl)
|
||||||
@ -26,44 +23,31 @@ struct OverviewSectionGenerator {
|
|||||||
var content = [OverviewSectionTemplate.Key : String]()
|
var content = [OverviewSectionTemplate.Key : String]()
|
||||||
content[.url] = relativeUrl
|
content[.url] = relativeUrl
|
||||||
content[.title] = metadata.title
|
content[.title] = metadata.title
|
||||||
content[.items] = try sectionContent(section: section, in: parent, language: language, shownItemCount: sectionItemCount)
|
content[.items] = sectionContent(section: section, in: parent, language: language, shownItemCount: sectionItemCount)
|
||||||
content[.more] = metadata.moreLinkTitle
|
content[.more] = metadata.moreLinkText
|
||||||
|
|
||||||
return multipleSectionsTemplate.generate(content)
|
return multipleSectionsTemplate.generate(content)
|
||||||
}
|
}
|
||||||
.joined(separator: "\n")
|
.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate(section: Section, language: String) throws -> String {
|
func generate(section: Element, language: String) -> String {
|
||||||
var content = [OverviewSectionCleanTemplate.Key : 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)
|
return singleSectionsTemplate.generate(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sectionContent(section: Section, in parent: SiteElement, language: String, shownItemCount: Int?) throws -> String {
|
private func sectionContent(section: Element, in parent: Element, language: String, shownItemCount: Int?) -> String {
|
||||||
let sectionItems: [SiteElement]
|
let sectionItems: [Element]
|
||||||
if let shownItemCount = shownItemCount {
|
if let shownItemCount = shownItemCount {
|
||||||
sectionItems = Array(section.sortedItems.prefix(shownItemCount))
|
sectionItems = Array(section.sortedItems.prefix(shownItemCount))
|
||||||
} else {
|
} else {
|
||||||
sectionItems = section.sortedItems
|
sectionItems = section.sortedItems
|
||||||
}
|
}
|
||||||
|
return generator.generateContent(
|
||||||
let items: [ThumbnailInfo] = sectionItems.map { item in
|
items: sectionItems,
|
||||||
#warning("Check if page exists for the language")
|
parent: parent,
|
||||||
let fullPageUrl = item.fullPageUrl(for: language)
|
language: language,
|
||||||
let relativePageUrl = parent.relativePathToFileWithPath(fullPageUrl)
|
style: section.thumbnailStyle)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,70 +12,67 @@ struct PageGenerator {
|
|||||||
|
|
||||||
private let factory: LocalizedSiteTemplate
|
private let factory: LocalizedSiteTemplate
|
||||||
|
|
||||||
private let files: FileProcessor
|
init(factory: LocalizedSiteTemplate) {
|
||||||
|
|
||||||
init(factory: LocalizedSiteTemplate, files: FileProcessor) {
|
|
||||||
self.factory = factory
|
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 {
|
guard !page.isExternalPage else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard !page.metadata.isDraft else {
|
guard page.state != .draft else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let path = page.fullPageUrl(for: language)
|
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")
|
#warning("Make prev and next navigation relative")
|
||||||
let metadata = page.localized(for: language)
|
let metadata = page.localized(for: language)
|
||||||
let nextLanguage = page.nextLanguage(for: language)
|
let nextLanguage = page.nextLanguage(for: language)
|
||||||
var content = [PageTemplate.Key : String]()
|
var content = [PageTemplate.Key : String]()
|
||||||
content[.head] = try makeHead(page: page, language: language)
|
content[.head] = factory.pageHead.generate(page: page, language: language)
|
||||||
content[.topBar] = factory.topBar.generate(section: page.sectionId, languageButton: nextLanguage)
|
let sectionUrl = page.sectionUrl(for: language)
|
||||||
|
content[.topBar] = factory.topBar.generate(sectionUrl: sectionUrl, languageButton: nextLanguage)
|
||||||
content[.contentClass] = "content"
|
content[.contentClass] = "content"
|
||||||
if !page.metadata.useCustomHeader {
|
if !page.useCustomHeader {
|
||||||
content[.header] = makeHeader(page: page.metadata, metadata: metadata, language: language, backText: backText)
|
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[.previousPageLinkText] = previousPage.unwrapped { factory.makePrevText($0.text) }
|
||||||
content[.previousPageUrl] = previousPage?.link
|
content[.previousPageUrl] = previousPage?.link
|
||||||
content[.nextPageLinkText] = nextPage.unwrapped { factory.makeNextText($0.text) }
|
content[.nextPageLinkText] = nextPage.unwrapped { factory.makeNextText($0.text) }
|
||||||
content[.nextPageUrl] = nextPage?.link
|
content[.nextPageUrl] = nextPage?.link
|
||||||
content[.footer] = try page.customFooterContent()
|
content[.footer] = page.customFooterContent()
|
||||||
|
|
||||||
let url = files.outputFolder.appendingPathComponent(path)
|
let url = files.urlInOutputFolder(path)
|
||||||
try factory.page.generate(content, to: url)
|
guard factory.page.generate(content, to: url) else {
|
||||||
}
|
return
|
||||||
|
|
||||||
private func makeContent(page: Page, language: String, url: URL) throws -> String {
|
|
||||||
guard url.exists else {
|
|
||||||
print("Generated empty page \(page.path)")
|
|
||||||
return factory.placeholder
|
|
||||||
}
|
}
|
||||||
print("Generated page \(page.path)")
|
log.add(info: "Generated \(pageContent == nil ? "empty page " : "")\(path)", source: page.path)
|
||||||
return try PageContentGenerator(factory: factory.factory, files: files)
|
|
||||||
.generate(page: page, language: language, at: url)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makeHead(page: Page, language: String) throws -> String {
|
private func makeContent(page: Element, language: String, path: String) -> String? {
|
||||||
let metadata = page.localized(for: language)
|
guard let content = files.contentOfOptionalFile(atPath: path, source: page.path) else {
|
||||||
let info = PageHeadInfo(
|
return nil
|
||||||
author: page.metadata.author ?? factory.author,
|
}
|
||||||
linkPreviewTitle: metadata.linkPreviewTitle,
|
return PageContentGenerator(factory: factory.factory)
|
||||||
linkPreviewDescription: metadata.linkPreviewDescription,
|
.generate(page: page, language: language, content: content)
|
||||||
linkPreviewImage: page.linkPreviewImage(for: language),
|
|
||||||
customHeadContent: try page.customHeadContent())
|
|
||||||
return try factory.pageHead.generate(page: info)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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]()
|
var content = [HeaderKey : String]()
|
||||||
content[.backLink] = factory.makeBackLink(text: backText, language: language)
|
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[.subtitle] = metadata.subtitle
|
||||||
content[.date] = factory.makeDateString(start: page.date, end: page.endDate)
|
content[.date] = factory.makeDateString(start: page.date, end: page.endDate)
|
||||||
return factory.factory.leftHeader.generate(content)
|
return factory.factory.leftHeader.generate(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func make(title: String, suffix: String) -> String {
|
||||||
|
"\(title)<span class=\"suffix\">\(suffix)</span>"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,60 +1,36 @@
|
|||||||
import Foundation
|
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 {
|
struct PageHeadGenerator {
|
||||||
|
|
||||||
|
static let linkPreviewDesiredImageWidth = 1600
|
||||||
|
|
||||||
let template: PageHeadTemplate
|
let template: PageHeadTemplate
|
||||||
|
|
||||||
let files: FileProcessor
|
init(factory: TemplateFactory) {
|
||||||
|
|
||||||
init(factory: TemplateFactory, files: FileProcessor) {
|
|
||||||
self.template = factory.pageHead
|
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]()
|
var content = [PageHeadTemplate.Key : String]()
|
||||||
content[.author] = page.author
|
content[.author] = page.author
|
||||||
content[.title] = page.linkPreviewTitle
|
content[.title] = metadata.linkPreviewTitle
|
||||||
content[.description] = page.linkPreviewDescription
|
content[.description] = metadata.linkPreviewDescription
|
||||||
if let image = page.linkPreviewImage {
|
if let image = page.linkPreviewImage(for: language) {
|
||||||
// Note: Generate separate destination link for the image,
|
// Note: Generate separate destination link for the image,
|
||||||
// since we don't want a single large image for thumbnails.
|
// since we don't want a single large image for thumbnails.
|
||||||
// Warning: Link preview source path must be relative to root
|
// Warning: Link preview source path must be relative to root
|
||||||
let linkPreviewImagePath = image.insert("-link", beforeLast: ".")
|
let linkPreviewImageName = image.insert("-link", beforeLast: ".")
|
||||||
try files.requireImage(
|
let sourceImagePath = page.pathRelativeToRootForContainedInputFile(image)
|
||||||
source: image,
|
let destinationImagePath = page.pathRelativeToRootForContainedInputFile(linkPreviewImageName)
|
||||||
destination: linkPreviewImagePath,
|
files.requireImage(
|
||||||
width: Site.linkPreviewDesiredImageWidth)
|
source: sourceImagePath,
|
||||||
#warning("Make link preview image path absolute")
|
destination: destinationImagePath,
|
||||||
content[.image] = "<meta property=\"og:image\" content=\"\(linkPreviewImagePath)\" />"
|
width: PageHeadGenerator.linkPreviewDesiredImageWidth)
|
||||||
|
content[.image] = "<meta property=\"og:image\" content=\"\(linkPreviewImageName)\" />"
|
||||||
}
|
}
|
||||||
content[.customPageContent] = page.customHeadContent
|
content[.customPageContent] = page.customHeadContent()
|
||||||
|
|
||||||
return template.generate(content)
|
return template.generate(content)
|
||||||
}
|
}
|
||||||
|
@ -2,60 +2,45 @@ import Foundation
|
|||||||
|
|
||||||
struct SiteGenerator {
|
struct SiteGenerator {
|
||||||
|
|
||||||
let site: Site
|
|
||||||
|
|
||||||
let templates: TemplateFactory
|
let templates: TemplateFactory
|
||||||
|
|
||||||
private let files: FileProcessor
|
init() throws {
|
||||||
|
let templatesFolder = files.urlInContentFolder("templates")
|
||||||
private var outputFolder: URL {
|
|
||||||
files.outputFolder
|
|
||||||
}
|
|
||||||
|
|
||||||
init(site: Site, files: FileProcessor) throws {
|
|
||||||
self.site = site
|
|
||||||
let templatesFolder = site.inputFolder.appendingPathComponent("templates")
|
|
||||||
self.templates = try TemplateFactory(templateFolder: templatesFolder)
|
self.templates = try TemplateFactory(templateFolder: templatesFolder)
|
||||||
self.files = files
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate() throws {
|
func generate(site: Element) throws {
|
||||||
try site.metadata.languages.forEach { metadata in
|
try site.languages.forEach { metadata in
|
||||||
let language = metadata.languageIdentifier
|
let language = metadata.language
|
||||||
let template = try LocalizedSiteTemplate(
|
let template = try LocalizedSiteTemplate(
|
||||||
factory: templates,
|
factory: templates,
|
||||||
language: language,
|
language: language,
|
||||||
site: site,
|
site: site)
|
||||||
files: files)
|
|
||||||
|
|
||||||
|
|
||||||
// Generate sections
|
// Generate sections
|
||||||
let overviewGenerator = OverviewPageGenerator(factory: template, files: files)
|
let overviewGenerator = OverviewPageGenerator(factory: template)
|
||||||
let pageGenerator = PageGenerator(factory: template, files: files)
|
let pageGenerator = PageGenerator(factory: template)
|
||||||
let backLinkText = try site.backLinkText(for: language)
|
var elementsToProcess: [Element] = site.elements
|
||||||
var elementsToProcess: [(element: SiteElement, backText: String?)] = site.elements.map { ($0, backLinkText) }
|
while let element = elementsToProcess.popLast() {
|
||||||
while let (element, backText) = elementsToProcess.popLast() {
|
// Move recursively down to all pages
|
||||||
if let section = element as? Section {
|
elementsToProcess.append(contentsOf: element.elements)
|
||||||
try overviewGenerator.generate(
|
|
||||||
section: section,
|
element.requiredFiles.forEach(files.require)
|
||||||
|
|
||||||
|
let backLinkText = element.backLinkText(for: language)
|
||||||
|
if !element.elements.isEmpty {
|
||||||
|
overviewGenerator.generate(
|
||||||
|
section: element,
|
||||||
language: language,
|
language: language,
|
||||||
backText: backText)
|
backText: backLinkText)
|
||||||
let elementBackText = try element.backLinkText(for: language)
|
} else {
|
||||||
let nestedElements = section.elements.map { ($0, elementBackText) }
|
|
||||||
elementsToProcess.append(contentsOf: nestedElements)
|
|
||||||
}
|
|
||||||
if let page = element as? Page {
|
|
||||||
#warning("Determine previous and next pages")
|
#warning("Determine previous and next pages")
|
||||||
try pageGenerator.generate(
|
pageGenerator.generate(
|
||||||
page: page,
|
page: element,
|
||||||
language: language,
|
language: language,
|
||||||
backText: backText ?? metadata.defaultBackLinkText,
|
backText: backLinkText,
|
||||||
nextPage: nil,
|
nextPage: nil,
|
||||||
previousPage: 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
|
// Generate front page
|
||||||
let relativeUrl = site.localizedPath(for: language)
|
let relativeUrl = site.localizedPath(for: language)
|
||||||
let indexPageUrl = outputFolder.appendingPathComponent(relativeUrl)
|
let indexPageUrl = files.urlInOutputFolder(relativeUrl)
|
||||||
let button = site.nextLanguage(for: language)
|
let button = site.nextLanguage(for: language)
|
||||||
try generator.generate(
|
generator.generate(
|
||||||
site: site,
|
site: site,
|
||||||
language: language,
|
language: language,
|
||||||
languageButton: button,
|
languageButton: button,
|
||||||
|
@ -1,14 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct ThumbnailInfo {
|
|
||||||
|
|
||||||
let url: String?
|
|
||||||
|
|
||||||
let imageFilePath: String
|
|
||||||
|
|
||||||
let imageHtmlUrl: String
|
|
||||||
|
|
||||||
let title: String
|
|
||||||
|
|
||||||
let cornerText: String?
|
|
||||||
}
|
|
@ -4,35 +4,37 @@ struct ThumbnailListGenerator {
|
|||||||
|
|
||||||
private let factory: TemplateFactory
|
private let factory: TemplateFactory
|
||||||
|
|
||||||
let files: FileProcessor
|
init(factory: TemplateFactory) {
|
||||||
|
|
||||||
init(factory: TemplateFactory, files: FileProcessor) {
|
|
||||||
self.factory = factory
|
self.factory = factory
|
||||||
self.files = files
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateContent(items: [ThumbnailInfo], style: ThumbnailStyle) throws -> String {
|
func generateContent(items: [Element], parent: Element, language: String, style: ThumbnailStyle) -> String {
|
||||||
try items.map { try itemContent($0, style: style) }
|
items.map { itemContent($0, parent: parent, language: language, style: style) }
|
||||||
.joined(separator: "\n")
|
.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]()
|
var content = [ThumbnailKey : String]()
|
||||||
content[.url] = thumbnail.url.unwrapped { "href=\"\($0)\"" }
|
content[.url] = "href=\"\(relativePageUrl)\""
|
||||||
content[.image] = thumbnail.imageHtmlUrl
|
content[.image] = relativeImageUrl
|
||||||
content[.title] = thumbnail.title
|
content[.title] = item.title(for: language)
|
||||||
content[.image2x] = thumbnail.imageHtmlUrl.insert("@2x", beforeLast: ".")
|
#warning("Generate thumbnail suffix")
|
||||||
content[.corner] = thumbnail.cornerText.unwrapped {
|
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
|
||||||
|
content[.corner] = item.cornerText(for: language).unwrapped {
|
||||||
factory.largeThumbnail.makeCorner(text: $0)
|
factory.largeThumbnail.makeCorner(text: $0)
|
||||||
}
|
}
|
||||||
|
|
||||||
try files.requireImage(
|
files.requireImage(
|
||||||
source: thumbnail.imageFilePath,
|
source: fullThumbnailPath,
|
||||||
destination: thumbnail.imageFilePath,
|
destination: fullThumbnailPath,
|
||||||
width: style.width,
|
width: style.width,
|
||||||
desiredHeight: style.height,
|
desiredHeight: style.height,
|
||||||
createDoubleVersion: true)
|
createDoubleVersion: true)
|
||||||
|
return factory.thumbnail(style: style).generate(content, shouldIndent: false)
|
||||||
return try factory.thumbnail(style: style).generate(content, shouldIndent: false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,238 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct Element {
|
|
||||||
|
|
||||||
static let overviewItemCountDefault = 6
|
|
||||||
|
|
||||||
/**
|
|
||||||
The author of the content.
|
|
||||||
|
|
||||||
If no author is set, then the author from the parent element is used.
|
|
||||||
*/
|
|
||||||
let author: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
The title used in the top bar of the website, next to the logo.
|
|
||||||
|
|
||||||
This title can be HTML content, and only the root level value is used.
|
|
||||||
*/
|
|
||||||
let topBarTitle: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
The url where the site will be deployed.
|
|
||||||
|
|
||||||
This value is required to build absolute links for link previews.
|
|
||||||
|
|
||||||
- Note: Only the root level value is used.
|
|
||||||
- Note: The path does not need to contain a trailing slash.
|
|
||||||
*/
|
|
||||||
let deployedBaseUrl: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
The (start) date of the element.
|
|
||||||
|
|
||||||
The date is printed on content pages and may also used for sorting elements,
|
|
||||||
depending on the `useManualSorting` property of the parent.
|
|
||||||
*/
|
|
||||||
let date: Date?
|
|
||||||
|
|
||||||
/**
|
|
||||||
The end date of the element.
|
|
||||||
|
|
||||||
This property can be used to specify a date range for a content page.
|
|
||||||
*/
|
|
||||||
let endDate: Date?
|
|
||||||
|
|
||||||
/**
|
|
||||||
The deployment state of the page.
|
|
||||||
|
|
||||||
- Note: This property defaults to ``PageState.standard`
|
|
||||||
*/
|
|
||||||
let state: PageState
|
|
||||||
|
|
||||||
/**
|
|
||||||
The sort index of the page for manual sorting.
|
|
||||||
|
|
||||||
- Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set.
|
|
||||||
*/
|
|
||||||
let sortIndex: Int?
|
|
||||||
|
|
||||||
/**
|
|
||||||
All files which may occur in content but is stored externally.
|
|
||||||
|
|
||||||
Missing files which would otherwise produce a warning are ignored when included here.
|
|
||||||
- Note: This property defaults to an empty set.
|
|
||||||
*/
|
|
||||||
let externalFiles: Set<String>
|
|
||||||
|
|
||||||
/**
|
|
||||||
Specifies additional files which should be copied to the destination when generating the content.
|
|
||||||
- Note: This property defaults to an empty set.
|
|
||||||
*/
|
|
||||||
let requiredFiles: Set<String>
|
|
||||||
|
|
||||||
/**
|
|
||||||
The style of thumbnail to use when generating overviews.
|
|
||||||
|
|
||||||
- Note: This property is only relevant for sections.
|
|
||||||
- Note: This property is inherited from the parent if not specified.
|
|
||||||
*/
|
|
||||||
let thumbnailStyle: ThumbnailStyle
|
|
||||||
|
|
||||||
/**
|
|
||||||
Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`.
|
|
||||||
|
|
||||||
- Note: This property is only relevant for sections.
|
|
||||||
- Note: This property defaults to `false`
|
|
||||||
*/
|
|
||||||
let useManualSorting: Bool
|
|
||||||
|
|
||||||
/**
|
|
||||||
The number of items to show when generating overviews of this element.
|
|
||||||
- Note: This property is only relevant for sections.
|
|
||||||
- Note: This property is inherited from the parent if not specified.
|
|
||||||
*/
|
|
||||||
let overviewItemCount: Int
|
|
||||||
|
|
||||||
/**
|
|
||||||
The localized metadata for each language.
|
|
||||||
*/
|
|
||||||
let languages: [LocalizedMetadata]
|
|
||||||
|
|
||||||
/**
|
|
||||||
All elements contained within the element.
|
|
||||||
|
|
||||||
If the element is a section, then this property contains the pages or subsections within.
|
|
||||||
*/
|
|
||||||
var elements: [Element] = []
|
|
||||||
|
|
||||||
/**
|
|
||||||
The url of the element's folder in the source hierarchy.
|
|
||||||
- Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property.
|
|
||||||
*/
|
|
||||||
let inputFolder: URL
|
|
||||||
|
|
||||||
/**
|
|
||||||
The path to the element's folder in the source hierarchy (without a leading slash).
|
|
||||||
*/
|
|
||||||
let path: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
Create the root element of a site.
|
|
||||||
|
|
||||||
The root element will recursively move into subfolders and build the site content
|
|
||||||
by looking for metadata files in each subfolder.
|
|
||||||
- Parameter folder: The root folder of the site content.
|
|
||||||
- Parameter context: The context to create the element (validation, file access, etc.)
|
|
||||||
*/
|
|
||||||
init?(atRoot folder: URL, with context: Context) throws {
|
|
||||||
let validation = context.validation
|
|
||||||
self.inputFolder = folder
|
|
||||||
self.path = ""
|
|
||||||
|
|
||||||
let source = GenericMetadata.metadataFileName
|
|
||||||
guard let metadata = try GenericMetadata(source: source, with: context) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
self.author = validation.required(metadata.author, name: "author", source: source) ?? "author"
|
|
||||||
self.topBarTitle = validation
|
|
||||||
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
|
|
||||||
self.deployedBaseUrl = validation
|
|
||||||
.required(metadata.deployedBaseUrl, name: "deployedBaseUrl", source: source) ?? "https://example.com"
|
|
||||||
self.date = validation.unused(metadata.date, "date", source: source)
|
|
||||||
self.endDate = validation.unused(metadata.endDate, "endDate", source: source)
|
|
||||||
self.state = validation.state(metadata.state, source: source)
|
|
||||||
self.sortIndex = validation.unused(metadata.sortIndex, "sortIndex", source: source)
|
|
||||||
self.externalFiles = metadata.externalFiles ?? []
|
|
||||||
self.requiredFiles = metadata.requiredFiles ?? []
|
|
||||||
self.thumbnailStyle = validation.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large
|
|
||||||
self.useManualSorting = validation.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true
|
|
||||||
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
|
|
||||||
self.languages = validation.required(metadata.languages, name: "languages", source: source)?
|
|
||||||
.compactMap { language in
|
|
||||||
.init(atRoot: folder, data: language, with: context)
|
|
||||||
} ?? []
|
|
||||||
try self.readElements(in: folder, source: nil, with: context)
|
|
||||||
}
|
|
||||||
|
|
||||||
mutating func readElements(in folder: URL, source: String?, with context: Context) throws {
|
|
||||||
let subFolders: [URL]
|
|
||||||
do {
|
|
||||||
subFolders = try FileSystem.folders(in: folder)
|
|
||||||
} catch {
|
|
||||||
context.validation.add(error: "Failed to read subfolders", source: source ?? "root", error: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.elements = try subFolders.compactMap { subFolder in
|
|
||||||
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent
|
|
||||||
return try Element(parent: self, folder: subFolder, with: context, path: s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init?(parent: Element, folder: URL, with: Context, path: String) throws {
|
|
||||||
let validation = context.validation
|
|
||||||
self.inputFolder = folder
|
|
||||||
self.path = path
|
|
||||||
|
|
||||||
let source = path + "/" + GenericMetadata.metadataFileName
|
|
||||||
guard let metadata = try GenericMetadata(source: source, with: context) else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
self.author = metadata.author ?? parent.author
|
|
||||||
self.topBarTitle = validation
|
|
||||||
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
|
|
||||||
self.deployedBaseUrl = validation
|
|
||||||
.unused(metadata.deployedBaseUrl, "deployedBaseUrl", source: source) ?? parent.deployedBaseUrl
|
|
||||||
let date = validation.date(from: metadata.date, property: "date", source: source).ifNil {
|
|
||||||
if !parent.useManualSorting {
|
|
||||||
validation.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.date = date
|
|
||||||
self.endDate = validation.date(from: metadata.endDate, property: "endDate", source: source).ifNotNil {
|
|
||||||
if date == nil {
|
|
||||||
validation.add(warning: "Set 'endDate', but no 'date'", source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.state = validation.state(metadata.state, source: source)
|
|
||||||
self.sortIndex = metadata.sortIndex.ifNil {
|
|
||||||
if parent.useManualSorting {
|
|
||||||
validation.add(error: "No 'sortIndex', but parent defines 'useManualSorting' = true", source: source)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TODO: Propagate external files from the parent if subpath matches?
|
|
||||||
self.externalFiles = metadata.externalFiles ?? []
|
|
||||||
self.requiredFiles = metadata.requiredFiles ?? []
|
|
||||||
self.thumbnailStyle = validation.thumbnailStyle(metadata.thumbnailStyle, source: source)
|
|
||||||
self.useManualSorting = metadata.useManualSorting ?? false
|
|
||||||
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
|
|
||||||
self.languages = parent.languages.compactMap { parentData in
|
|
||||||
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
|
|
||||||
validation.add(info: "Language '\(parentData.language)' not found", source: source)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return .init(folder: folder, data: data, source: source, parent: parentData, with: context)
|
|
||||||
}
|
|
||||||
// Check that each 'language' tag is present, and that all languages appear in the parent
|
|
||||||
validation.required(metadata.languages, name: "languages", source: source)?
|
|
||||||
.compactMap { validation.required($0.language, name: "language", source: source) }
|
|
||||||
.filter { language in
|
|
||||||
!parent.languages.contains { $0.language == language }
|
|
||||||
}
|
|
||||||
.forEach {
|
|
||||||
validation.add(warning: "Language '\($0)' not found in parent, so not generated", source: source)
|
|
||||||
}
|
|
||||||
try self.readElements(in: folder, source: path, with: context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Debug
|
|
||||||
|
|
||||||
extension Element {
|
|
||||||
|
|
||||||
func printTree(indentation: String = "") {
|
|
||||||
print(indentation + "/" + path)
|
|
||||||
elements.forEach { $0.printTree(indentation: indentation + " ") }
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,173 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
enum FileAccessError: Error {
|
|
||||||
case failedToReadFile(String, Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
final class FileAccess {
|
|
||||||
|
|
||||||
static let accessTimesFileName = "access.json"
|
|
||||||
|
|
||||||
let errorOutput: ErrorOutput
|
|
||||||
|
|
||||||
let sourceFolder: URL
|
|
||||||
|
|
||||||
private let source = "FileAccess"
|
|
||||||
|
|
||||||
private var modificationTimeCacheFile: URL {
|
|
||||||
sourceFolder.appendingPathComponent(FileAccess.accessTimesFileName)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
The time stamps of last modified times for all accessed source files.
|
|
||||||
|
|
||||||
The key is the relative path to the file from the source
|
|
||||||
*/
|
|
||||||
private var sourceLastModifiedTimes: [String : Date] = [:]
|
|
||||||
|
|
||||||
private var changedFiles: Set<String> = []
|
|
||||||
|
|
||||||
private var accessedFiles: Set<String> = []
|
|
||||||
|
|
||||||
init(in root: URL, errorOutput: ErrorOutput) {
|
|
||||||
self.sourceFolder = root
|
|
||||||
self.errorOutput = errorOutput
|
|
||||||
|
|
||||||
loadSavedModificationTimes()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func loadSavedModificationTimes() {
|
|
||||||
let url = modificationTimeCacheFile
|
|
||||||
guard url.exists else {
|
|
||||||
errorOutput.add(info: "No file modification times loaded, regarding all content as new", source: source)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let data: Data
|
|
||||||
do {
|
|
||||||
data = try Data(contentsOf: url)
|
|
||||||
} catch {
|
|
||||||
errorOutput.add(
|
|
||||||
warning: "File modification times data not read, regarding all content as new",
|
|
||||||
source: source,
|
|
||||||
error: error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
self.sourceLastModifiedTimes = try JSONDecoder().decode(from: data)
|
|
||||||
} catch {
|
|
||||||
errorOutput.add(
|
|
||||||
warning: "File modification times not decoded, regarding all content as new",
|
|
||||||
source: source,
|
|
||||||
error: error)
|
|
||||||
try? url.delete()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func didAccess(inputPath: String, modified lastModified: Date, source: String) {
|
|
||||||
accessedFiles.insert(inputPath)
|
|
||||||
guard let previousDate = sourceLastModifiedTimes[inputPath] else {
|
|
||||||
// File not processed before, so mark as changed
|
|
||||||
changedFiles.insert(inputPath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard lastModified > previousDate else {
|
|
||||||
// File is unchanged
|
|
||||||
return
|
|
||||||
}
|
|
||||||
changedFiles.insert(inputPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func lastModifiedTime(of url: URL) -> Date? {
|
|
||||||
guard url.exists else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
||||||
guard let date = attributes[.modificationDate] as? Date else {
|
|
||||||
errorOutput.add(warning: "Failed to read modification time of \(url.path)", source: source)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return date
|
|
||||||
} catch {
|
|
||||||
errorOutput.add(warning: "Failed to read file attributes of \(url.path)", source: source)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadStringContent(inputPath: String) throws -> String? {
|
|
||||||
try load(inputPath: inputPath, String.init)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadDataContent(inputPath: String) throws -> Data? {
|
|
||||||
try load(inputPath: inputPath) { try Data(contentsOf: $0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private func load<T>(inputPath: String, _ closure: (URL) throws -> T) rethrows -> T? {
|
|
||||||
let url = sourceFolder.appendingPathComponent(inputPath)
|
|
||||||
guard let modifiedDate = lastModifiedTime(of: url) else {
|
|
||||||
sourceLastModifiedTimes[inputPath] = nil
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let content = try closure(url)
|
|
||||||
didAccess(inputPath: inputPath, modified: modifiedDate, source: source)
|
|
||||||
return content
|
|
||||||
} catch {
|
|
||||||
throw FileAccessError.failedToReadFile(inputPath, error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func didGenerateAllFiles() {
|
|
||||||
for file in changedFiles {
|
|
||||||
let url = sourceFolder.appendingPathComponent(file)
|
|
||||||
guard let date = lastModifiedTime(of: url) else {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sourceLastModifiedTimes[file] = date
|
|
||||||
}
|
|
||||||
do {
|
|
||||||
let data = try JSONEncoder().encode(sourceLastModifiedTimes)
|
|
||||||
try data.write(to: modificationTimeCacheFile)
|
|
||||||
} catch {
|
|
||||||
errorOutput.add(warning: "Failed to save modification times", source: source, error: error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printChangedFilesOverview() {
|
|
||||||
let count = changedFiles.count
|
|
||||||
guard count > 0 else {
|
|
||||||
print("No files modified")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
print("\(count) files modified:")
|
|
||||||
changedFiles.prefix(10).forEach { print(" " + $0) }
|
|
||||||
if count > 10 {
|
|
||||||
print(" ...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printAccessedFilesOverview() {
|
|
||||||
let count = accessedFiles.count
|
|
||||||
guard count > 0 else {
|
|
||||||
print("No files accessed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
print("\(count) files accessed:")
|
|
||||||
accessedFiles.prefix(10).forEach { print(" " + $0) }
|
|
||||||
if count > 10 {
|
|
||||||
print(" ...")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func printAllTouchedFiles() {
|
|
||||||
print("\(accessedFiles.count) files accessed:")
|
|
||||||
accessedFiles.sorted().forEach { file in
|
|
||||||
if changedFiles.contains(file) {
|
|
||||||
print(" \(file) (changed)")
|
|
||||||
} else {
|
|
||||||
print(" " + file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,7 +2,7 @@ import Foundation
|
|||||||
|
|
||||||
protocol ThumbnailTemplate {
|
protocol ThumbnailTemplate {
|
||||||
|
|
||||||
func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) throws -> String
|
func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) -> String
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ThumbnailKey: String, CaseIterable {
|
enum ThumbnailKey: String, CaseIterable {
|
||||||
|
@ -31,27 +31,14 @@ struct LocalizedSiteTemplate {
|
|||||||
topBar.language
|
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
|
// MARK: Pages
|
||||||
|
|
||||||
var page: PageTemplate {
|
var page: PageTemplate {
|
||||||
factory.page
|
factory.page
|
||||||
}
|
}
|
||||||
|
|
||||||
init(factory: TemplateFactory, language: String, site: Site, files: FileProcessor) throws {
|
init(factory: TemplateFactory, language: String, site: Element) throws {
|
||||||
self.author = site.metadata.author
|
self.author = site.author
|
||||||
self.factory = factory
|
self.factory = factory
|
||||||
|
|
||||||
let df = DateFormatter()
|
let df = DateFormatter()
|
||||||
@ -70,28 +57,23 @@ struct LocalizedSiteTemplate {
|
|||||||
df3.locale = Locale(identifier: language)
|
df3.locale = Locale(identifier: language)
|
||||||
self.day = df3
|
self.day = df3
|
||||||
|
|
||||||
|
let metadata = site.localized(for: language)
|
||||||
|
|
||||||
let sections = site.elements.map {
|
let sections = site.elements.map {
|
||||||
PrefilledTopBarTemplate.SectionInfo(
|
PrefilledTopBarTemplate.SectionInfo(
|
||||||
id: $0.sectionId,
|
|
||||||
name: $0.title(for: language),
|
name: $0.title(for: language),
|
||||||
url: "\($0.path)/\(language).html")
|
url: "\($0.path)/\(language).html")
|
||||||
}
|
}
|
||||||
|
|
||||||
let metadata = site.localized(for: language)
|
|
||||||
|
|
||||||
let title = site.metadata.topBarTitle ?? metadata.linkPreviewTitle
|
|
||||||
|
|
||||||
self.topBar = try .init(
|
self.topBar = try .init(
|
||||||
template: factory.topBar,
|
template: factory.topBar,
|
||||||
language: language,
|
language: language,
|
||||||
sections: sections,
|
sections: sections,
|
||||||
topBarWebsiteTitle: title)
|
topBarWebsiteTitle: site.topBarTitle)
|
||||||
self.pageHead = PageHeadGenerator(
|
self.pageHead = PageHeadGenerator(
|
||||||
factory: factory,
|
factory: factory)
|
||||||
files: files)
|
|
||||||
self.overviewSection = OverviewSectionGenerator(
|
self.overviewSection = OverviewSectionGenerator(
|
||||||
factory: factory,
|
factory: factory)
|
||||||
files: files)
|
|
||||||
|
|
||||||
self.placeholder = factory.placeholder.generate([
|
self.placeholder = factory.placeholder.generate([
|
||||||
.title: metadata.placeholderTitle,
|
.title: metadata.placeholderTitle,
|
||||||
@ -108,8 +90,6 @@ struct LocalizedSiteTemplate {
|
|||||||
return backNavigation.generate(content)
|
return backNavigation.generate(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
#warning("Move HTML code to single location")
|
|
||||||
|
|
||||||
func makePrevText(_ text: String) -> String {
|
func makePrevText(_ text: String) -> String {
|
||||||
"<span class=\"icon-back\"></span>\(text)"
|
"<span class=\"icon-back\"></span>\(text)"
|
||||||
}
|
}
|
||||||
@ -118,7 +98,10 @@ struct LocalizedSiteTemplate {
|
|||||||
"\(text)<span class=\"icon-next\"></span>"
|
"\(text)<span class=\"icon-next\"></span>"
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeDateString(start: Date, end: Date?) -> String {
|
func makeDateString(start: Date?, end: Date?) -> String {
|
||||||
|
guard let start = start else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
guard let end = end else {
|
guard let end = end else {
|
||||||
return fullDateFormatter.string(from: start)
|
return fullDateFormatter.string(from: start)
|
||||||
}
|
}
|
||||||
|
@ -17,19 +17,19 @@ struct PrefilledTopBarTemplate {
|
|||||||
self.topBarWebsiteTitle = topBarWebsiteTitle
|
self.topBarWebsiteTitle = topBarWebsiteTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate(section: String?, languageButton: String?) -> String {
|
func generate(sectionUrl: String?, languageButton: String?) -> String {
|
||||||
var content = [TopBarTemplate.Key : String]()
|
var content = [TopBarTemplate.Key : String]()
|
||||||
content[.title] = topBarWebsiteTitle
|
content[.title] = topBarWebsiteTitle
|
||||||
content[.titleLink] = topBarWebsiteTitle(language: language)
|
content[.titleLink] = topBarWebsiteTitle(language: language)
|
||||||
content[.elements] = elements(activeSection: section)
|
content[.elements] = elements(activeSectionUrl: sectionUrl)
|
||||||
content[.languageButton] = languageButton.unwrapped(topBarLanguageButton) ?? ""
|
content[.languageButton] = languageButton.unwrapped(topBarLanguageButton) ?? ""
|
||||||
return topBar.generate(content)
|
return topBar.generate(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func elements(activeSection: String?) -> String {
|
private func elements(activeSectionUrl: String?) -> String {
|
||||||
sections
|
sections
|
||||||
.map {
|
.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")
|
.joined(separator: "\n")
|
||||||
}
|
}
|
||||||
@ -49,8 +49,6 @@ struct PrefilledTopBarTemplate {
|
|||||||
|
|
||||||
struct SectionInfo {
|
struct SectionInfo {
|
||||||
|
|
||||||
let id: String
|
|
||||||
|
|
||||||
let name: String
|
let name: String
|
||||||
|
|
||||||
let url: String
|
let url: String
|
||||||
|
@ -20,17 +20,13 @@ extension Template {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(from url: URL) throws {
|
init(from url: URL) throws {
|
||||||
let raw = try wrap(.failedToLoadTemplate(url.lastPathComponent)) {
|
let raw = try String(contentsOf: url)
|
||||||
try String(contentsOf: url)
|
|
||||||
}
|
|
||||||
self.init(raw: raw)
|
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)
|
let content = generate(content)
|
||||||
try wrap(.failedToWriteFile(url.path)) {
|
return files.write(content, to: url)
|
||||||
try content.createFolderAndWrite(to: url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func generate(_ content: [Key : String], shouldIndent: Bool = false) -> String {
|
func generate(_ content: [Key : String], shouldIndent: Bool = false) -> String {
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace")
|
private let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace")
|
||||||
let outputDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace/Site")
|
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 {
|
do {
|
||||||
guard let element = try Element(atRoot: contentDirectory, with: context) else {
|
guard let element = try Element(atRoot: contentDirectory) else {
|
||||||
exit(0)
|
exit(0)
|
||||||
}
|
}
|
||||||
siteData = element
|
siteData = element
|
||||||
@ -17,26 +18,13 @@ do {
|
|||||||
}
|
}
|
||||||
|
|
||||||
siteData.printTree()
|
siteData.printTree()
|
||||||
context.fileSystem.printAllTouchedFiles()
|
|
||||||
context.fileSystem.didGenerateAllFiles()
|
|
||||||
|
|
||||||
exit(0)
|
private let siteGenerator = try SiteGenerator()
|
||||||
|
try siteGenerator.generate(site: siteData)
|
||||||
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()
|
|
||||||
|
|
||||||
print("Pages generated")
|
print("Pages generated")
|
||||||
try files.createImages()
|
files.createImages()
|
||||||
print("Images generated")
|
print("Images generated")
|
||||||
try files.copyRequiredFiles()
|
files.copyRequiredFiles()
|
||||||
print("Required files copied")
|
print("Required files copied")
|
||||||
|
files.writeHashes()
|
||||||
#warning("Check that all metadata for each language is present")
|
|
||||||
|
Loading…
Reference in New Issue
Block a user