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 */
|
||||
E22E8763289D84C300E51191 /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8762289D84C300E51191 /* main.swift */; };
|
||||
E22E876A289D84FD00E51191 /* Section+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8769289D84FD00E51191 /* Section+Metadata.swift */; };
|
||||
E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E876B289D855D00E51191 /* ThumbnailStyle.swift */; };
|
||||
E22E876E289D868100E51191 /* Site+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */; };
|
||||
E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */; };
|
||||
E22E8778289DA0E100E51191 /* GenerationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8777289DA0E100E51191 /* GenerationError.swift */; };
|
||||
E22E877A289DA9F900E51191 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8779289DA9F900E51191 /* Site.swift */; };
|
||||
E22E877D289DBA0A00E51191 /* OverviewSectionGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */; };
|
||||
E22E877F289DC11F00E51191 /* Site+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E877E289DC11F00E51191 /* Site+Metadata.swift */; };
|
||||
E22E8782289DCCB600E51191 /* Section.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8781289DCCB600E51191 /* Section.swift */; };
|
||||
E22E8784289DCD5E00E51191 /* Section+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */; };
|
||||
E22E8787289DDF4C00E51191 /* Page.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8786289DDF4C00E51191 /* Page.swift */; };
|
||||
E22E8789289DDF5700E51191 /* Page+Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8788289DDF5700E51191 /* Page+Metadata.swift */; };
|
||||
E22E878C289E4A8900E51191 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = E22E878B289E4A8900E51191 /* Ink */; };
|
||||
E22E8793289E7EC700E51191 /* Page+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */; };
|
||||
E22E8795289E81D700E51191 /* FileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8794289E81D700E51191 /* FileSystem.swift */; };
|
||||
E22E8798289EA42C00E51191 /* FileProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8797289EA42C00E51191 /* FileProcessor.swift */; };
|
||||
E22E8795289E81D700E51191 /* URL+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8794289E81D700E51191 /* URL+Extensions.swift */; };
|
||||
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879A289EE02F00E51191 /* Optional+Extensions.swift */; };
|
||||
E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */; };
|
||||
E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */; };
|
||||
@ -33,11 +22,12 @@
|
||||
E22E87AC289F1D3700E51191 /* Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AB289F1D3700E51191 /* Template.swift */; };
|
||||
E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AD289F1E0000E51191 /* String+Extensions.swift */; };
|
||||
E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */; };
|
||||
E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B1289F296700E51191 /* ThumbnailInfo.swift */; };
|
||||
E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B5289FF67B00E51191 /* Metadata.swift */; };
|
||||
E253C86928AFD86E0076B6D0 /* FileAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C86828AFD86E0076B6D0 /* FileAccess.swift */; };
|
||||
E253C86B28AFE0980076B6D0 /* Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C86A28AFE0980076B6D0 /* Context.swift */; };
|
||||
E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */; };
|
||||
E253C87728B767D50076B6D0 /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87628B767D50076B6D0 /* MediaType.swift */; };
|
||||
E253C87A28B810090076B6D0 /* ImageOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87928B810090076B6D0 /* ImageOutput.swift */; };
|
||||
E253C87C28B8BFB80076B6D0 /* FileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87B28B8BFB80076B6D0 /* FileSystem.swift */; };
|
||||
E253C87F28B8FBB00076B6D0 /* Data+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */; };
|
||||
E253C88128B8FBFF0076B6D0 /* NSSize+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */; };
|
||||
E253C88328B8FC470076B6D0 /* NSImage+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */; };
|
||||
E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */; };
|
||||
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D628A022C500102A25 /* TemplateFactory.swift */; };
|
||||
E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */; };
|
||||
@ -47,10 +37,7 @@
|
||||
E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E228A037F900102A25 /* PageTemplate.swift */; };
|
||||
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */; };
|
||||
E2C5A5E928A0451C00102A25 /* LocalizedSiteTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */; };
|
||||
E2C5A5EC28A055E900102A25 /* SiteElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5EB28A055E900102A25 /* SiteElement.swift */; };
|
||||
E2D55ED928A1BAD800B9453E /* LanguageContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */; };
|
||||
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */; };
|
||||
E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */; };
|
||||
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */; };
|
||||
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */; };
|
||||
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */; };
|
||||
@ -63,7 +50,7 @@
|
||||
E2F8FA3428AD6F3400632026 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3328AD6F3400632026 /* Element.swift */; };
|
||||
E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */; };
|
||||
E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3728AE27A500632026 /* ContentError.swift */; };
|
||||
E2F8FA3A28AE313A00632026 /* ErrorOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3928AE313A00632026 /* ErrorOutput.swift */; };
|
||||
E2F8FA3A28AE313A00632026 /* ValidationLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3928AE313A00632026 /* ValidationLog.swift */; };
|
||||
E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@ -82,21 +69,10 @@
|
||||
/* Begin PBXFileReference section */
|
||||
E22E875F289D84C300E51191 /* WebsiteGenerator */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = WebsiteGenerator; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
E22E8762289D84C300E51191 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||
E22E8769289D84FD00E51191 /* Section+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+Metadata.swift"; sourceTree = "<group>"; };
|
||||
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailStyle.swift; sourceTree = "<group>"; };
|
||||
E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexPageGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E8777289DA0E100E51191 /* GenerationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationError.swift; sourceTree = "<group>"; };
|
||||
E22E8779289DA9F900E51191 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; };
|
||||
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E877E289DC11F00E51191 /* Site+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+Metadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8781289DCCB600E51191 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = "<group>"; };
|
||||
E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8786289DDF4C00E51191 /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; };
|
||||
E22E8788289DDF5700E51191 /* Page+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Metadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8794289E81D700E51191 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = "<group>"; };
|
||||
E22E8797289EA42C00E51191 /* FileProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileProcessor.swift; sourceTree = "<group>"; };
|
||||
E22E8794289E81D700E51191 /* URL+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailListGenerator.swift; sourceTree = "<group>"; };
|
||||
@ -106,11 +82,12 @@
|
||||
E22E87AB289F1D3700E51191 /* Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template.swift; sourceTree = "<group>"; };
|
||||
E22E87AD289F1E0000E51191 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefilledTopBarTemplate.swift; sourceTree = "<group>"; };
|
||||
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailInfo.swift; sourceTree = "<group>"; };
|
||||
E22E87B5289FF67B00E51191 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||
E253C86828AFD86E0076B6D0 /* FileAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccess.swift; sourceTree = "<group>"; };
|
||||
E253C86A28AFE0980076B6D0 /* Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = "<group>"; };
|
||||
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadataProvider.swift; sourceTree = "<group>"; };
|
||||
E253C87628B767D50076B6D0 /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = "<group>"; };
|
||||
E253C87928B810090076B6D0 /* ImageOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageOutput.swift; sourceTree = "<group>"; };
|
||||
E253C87B28B8BFB80076B6D0 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = "<group>"; };
|
||||
E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = "<group>"; };
|
||||
E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadTemplate.swift; sourceTree = "<group>"; };
|
||||
@ -120,10 +97,7 @@
|
||||
E2C5A5E228A037F900102A25 /* PageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSiteTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5EB28A055E900102A25 /* SiteElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteElement.swift; sourceTree = "<group>"; };
|
||||
E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageContainer.swift; sourceTree = "<group>"; };
|
||||
E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionCleanTemplate.swift; sourceTree = "<group>"; };
|
||||
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadata.swift; sourceTree = "<group>"; };
|
||||
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = "<group>"; };
|
||||
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = "<group>"; };
|
||||
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = "<group>"; };
|
||||
@ -135,7 +109,7 @@
|
||||
E2F8FA3328AD6F3400632026 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = "<group>"; };
|
||||
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Element+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||
E2F8FA3728AE27A500632026 /* ContentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentError.swift; sourceTree = "<group>"; };
|
||||
E2F8FA3928AE313A00632026 /* ErrorOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorOutput.swift; sourceTree = "<group>"; };
|
||||
E2F8FA3928AE313A00632026 /* ValidationLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidationLog.swift; sourceTree = "<group>"; };
|
||||
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decodable+Extensions.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@ -171,17 +145,12 @@
|
||||
E22E8761289D84C300E51191 /* WebsiteGenerator */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2F8FA2E28AD44FF00632026 /* Generic */,
|
||||
E22E8762289D84C300E51191 /* main.swift */,
|
||||
E22E87A1289F0BF000E51191 /* Content */,
|
||||
E253C87828B80AAF0076B6D0 /* Files */,
|
||||
E2F8FA2E28AD44FF00632026 /* Content */,
|
||||
E22E87A2289F0C6200E51191 /* Generators */,
|
||||
E2C5A5D328A0222B00102A25 /* Templates */,
|
||||
E22E8799289EE02300E51191 /* Extensions */,
|
||||
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */,
|
||||
E22E8797289EA42C00E51191 /* FileProcessor.swift */,
|
||||
E22E8777289DA0E100E51191 /* GenerationError.swift */,
|
||||
E2F8FA2728ACD84400632026 /* VideoType.swift */,
|
||||
E22E8794289E81D700E51191 /* FileSystem.swift */,
|
||||
);
|
||||
path = WebsiteGenerator;
|
||||
sourceTree = "<group>";
|
||||
@ -192,36 +161,18 @@
|
||||
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */,
|
||||
E22E87AD289F1E0000E51191 /* String+Extensions.swift */,
|
||||
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */,
|
||||
E253C87E28B8FBB00076B6D0 /* Data+Extensions.swift */,
|
||||
E22E8794289E81D700E51191 /* URL+Extensions.swift */,
|
||||
E253C88028B8FBFF0076B6D0 /* NSSize+Extensions.swift */,
|
||||
E253C88228B8FC470076B6D0 /* NSImage+Extensions.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E22E87A1289F0BF000E51191 /* Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2C5A5EB28A055E900102A25 /* SiteElement.swift */,
|
||||
E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */,
|
||||
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */,
|
||||
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */,
|
||||
E22E8786289DDF4C00E51191 /* Page.swift */,
|
||||
E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */,
|
||||
E22E8788289DDF5700E51191 /* Page+Metadata.swift */,
|
||||
E22E8781289DCCB600E51191 /* Section.swift */,
|
||||
E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */,
|
||||
E22E8769289D84FD00E51191 /* Section+Metadata.swift */,
|
||||
E22E8779289DA9F900E51191 /* Site.swift */,
|
||||
E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */,
|
||||
E22E877E289DC11F00E51191 /* Site+Metadata.swift */,
|
||||
E22E87B5289FF67B00E51191 /* Metadata.swift */,
|
||||
);
|
||||
path = Content;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E22E87A2289F0C6200E51191 /* Generators */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */,
|
||||
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */,
|
||||
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */,
|
||||
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */,
|
||||
E22E87A3289F0C7000E51191 /* SiteGenerator.swift */,
|
||||
@ -233,6 +184,19 @@
|
||||
path = Generators;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E253C87828B80AAF0076B6D0 /* Files */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2F8FA3728AE27A500632026 /* ContentError.swift */,
|
||||
E2F8FA3928AE313A00632026 /* ValidationLog.swift */,
|
||||
E253C87928B810090076B6D0 /* ImageOutput.swift */,
|
||||
E253C87B28B8BFB80076B6D0 /* FileSystem.swift */,
|
||||
E253C87628B767D50076B6D0 /* MediaType.swift */,
|
||||
E2F8FA2728ACD84400632026 /* VideoType.swift */,
|
||||
);
|
||||
path = Files;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2C5A5D328A0222B00102A25 /* Templates */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@ -279,20 +243,17 @@
|
||||
path = Filled;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2F8FA2E28AD44FF00632026 /* Generic */ = {
|
||||
E2F8FA2E28AD44FF00632026 /* Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E253C86A28AFE0980076B6D0 /* Context.swift */,
|
||||
E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */,
|
||||
E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */,
|
||||
E2F8FA2F28AD450B00632026 /* PageState.swift */,
|
||||
E2F8FA3328AD6F3400632026 /* Element.swift */,
|
||||
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */,
|
||||
E2F8FA3728AE27A500632026 /* ContentError.swift */,
|
||||
E2F8FA3928AE313A00632026 /* ErrorOutput.swift */,
|
||||
E253C86828AFD86E0076B6D0 /* FileAccess.swift */,
|
||||
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */,
|
||||
);
|
||||
path = Generic;
|
||||
path = Content;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
@ -363,60 +324,47 @@
|
||||
files = (
|
||||
E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */,
|
||||
E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */,
|
||||
E2F8FA3A28AE313A00632026 /* ErrorOutput.swift in Sources */,
|
||||
E22E876E289D868100E51191 /* Site+LocalizedMetadata.swift in Sources */,
|
||||
E2F8FA3A28AE313A00632026 /* ValidationLog.swift in Sources */,
|
||||
E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */,
|
||||
E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */,
|
||||
E2F8FA2D28AD2F5300632026 /* GenericMetadata.swift in Sources */,
|
||||
E22E8798289EA42C00E51191 /* FileProcessor.swift in Sources */,
|
||||
E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */,
|
||||
E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */,
|
||||
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */,
|
||||
E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */,
|
||||
E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */,
|
||||
E22E876A289D84FD00E51191 /* Section+Metadata.swift in Sources */,
|
||||
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */,
|
||||
E253C87C28B8BFB80076B6D0 /* FileSystem.swift in Sources */,
|
||||
E2F8FA3428AD6F3400632026 /* Element.swift in Sources */,
|
||||
E253C87F28B8FBB00076B6D0 /* Data+Extensions.swift in Sources */,
|
||||
E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */,
|
||||
E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */,
|
||||
E22E8793289E7EC700E51191 /* Page+LocalizedMetadata.swift in Sources */,
|
||||
E22E877D289DBA0A00E51191 /* OverviewSectionGenerator.swift in Sources */,
|
||||
E22E8782289DCCB600E51191 /* Section.swift in Sources */,
|
||||
E22E877F289DC11F00E51191 /* Site+Metadata.swift in Sources */,
|
||||
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */,
|
||||
E22E87A4289F0C7000E51191 /* SiteGenerator.swift in Sources */,
|
||||
E22E87AC289F1D3700E51191 /* Template.swift in Sources */,
|
||||
E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */,
|
||||
E2F8FA3028AD450B00632026 /* PageState.swift in Sources */,
|
||||
E22E8784289DCD5E00E51191 /* Section+LocalizedMetadata.swift in Sources */,
|
||||
E22E8789289DDF5700E51191 /* Page+Metadata.swift in Sources */,
|
||||
E2C5A5EC28A055E900102A25 /* SiteElement.swift in Sources */,
|
||||
E253C87728B767D50076B6D0 /* MediaType.swift in Sources */,
|
||||
E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */,
|
||||
E22E87A8289F0E7B00E51191 /* PageGenerator.swift in Sources */,
|
||||
E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */,
|
||||
E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */,
|
||||
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */,
|
||||
E253C88328B8FC470076B6D0 /* NSImage+Extensions.swift in Sources */,
|
||||
E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */,
|
||||
E2C5A5DB28A02F9000102A25 /* TopBarTemplate.swift in Sources */,
|
||||
E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */,
|
||||
E22E8778289DA0E100E51191 /* GenerationError.swift in Sources */,
|
||||
E2D55ED928A1BAD800B9453E /* LanguageContainer.swift in Sources */,
|
||||
E2C5A5E928A0451C00102A25 /* LocalizedSiteTemplate.swift in Sources */,
|
||||
E2C5A5E128A0373300102A25 /* ThumbnailTemplate.swift in Sources */,
|
||||
E22E8795289E81D700E51191 /* FileSystem.swift in Sources */,
|
||||
E22E8795289E81D700E51191 /* URL+Extensions.swift in Sources */,
|
||||
E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */,
|
||||
E22E8763289D84C300E51191 /* main.swift in Sources */,
|
||||
E253C86B28AFE0980076B6D0 /* Context.swift in Sources */,
|
||||
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */,
|
||||
E22E877A289DA9F900E51191 /* Site.swift in Sources */,
|
||||
E2F8FA3228AD456C00632026 /* GenericMetadata+Localized.swift in Sources */,
|
||||
E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */,
|
||||
E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */,
|
||||
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */,
|
||||
E22E8787289DDF4C00E51191 /* Page.swift in Sources */,
|
||||
E253C87A28B810090076B6D0 /* ImageOutput.swift in Sources */,
|
||||
E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */,
|
||||
E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */,
|
||||
E253C86928AFD86E0076B6D0 /* FileAccess.swift in Sources */,
|
||||
E253C88128B8FBFF0076B6D0 /* NSSize+Extensions.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -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>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>E22E875E289D84C300E51191</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -41,9 +41,9 @@ extension Element {
|
||||
/**
|
||||
The file name of the link preview image.
|
||||
- Note: The image must be located in the element folder.
|
||||
- Note: If `nil` is specified, then the (localized) thumbnail is used.
|
||||
- Note: If `nil` is specified, then the (localized) thumbnail is used, if available.
|
||||
*/
|
||||
let linkPreviewImage: String
|
||||
let linkPreviewImage: String?
|
||||
|
||||
/**
|
||||
The description text for the link preview.
|
||||
@ -63,10 +63,16 @@ extension Element {
|
||||
The text on the back navigation link of **contained** elements.
|
||||
|
||||
This text does not appear on the section page, but on the pages contained within the section.
|
||||
- Note: If this property is not specified, then the `defaultBackLinkText` is used
|
||||
*/
|
||||
let backLinkText: String
|
||||
|
||||
/**
|
||||
The text on the back navigation link of the **parent** element.
|
||||
|
||||
This text appears on the section page, but not on the pages contained within the section.
|
||||
*/
|
||||
let parentBackLinkText: String
|
||||
|
||||
/**
|
||||
The text to show as a title for placeholder boxes
|
||||
|
||||
@ -118,8 +124,7 @@ extension Element {
|
||||
|
||||
extension Element.LocalizedMetadata {
|
||||
|
||||
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata, with context: Context) {
|
||||
let validation = context.validation
|
||||
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata) {
|
||||
// Go through all elements and check them for completeness
|
||||
// In the end, check that all required elements are present
|
||||
var isComplete = true
|
||||
@ -127,43 +132,43 @@ extension Element.LocalizedMetadata {
|
||||
isComplete = false
|
||||
}
|
||||
let source = "root"
|
||||
self.language = validation
|
||||
self.language = log
|
||||
.required(data.language, name: "language", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.title = validation
|
||||
self.title = log
|
||||
.required(data.title, name: "title", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.subtitle = data.subtitle
|
||||
self.description = data.description
|
||||
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
|
||||
self.linkPreviewImage = validation
|
||||
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source) ?? ""
|
||||
self.linkPreviewImage = log
|
||||
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
|
||||
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
|
||||
self.linkPreviewDescription = validation
|
||||
self.linkPreviewDescription = log
|
||||
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
|
||||
self.backLinkText = validation
|
||||
self.backLinkText = log
|
||||
.required(data.backLinkText, name: "backLinkText", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.placeholderTitle = validation
|
||||
self.parentBackLinkText = "" // Root has no parent
|
||||
self.placeholderTitle = log
|
||||
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.placeholderText = validation
|
||||
self.placeholderText = log
|
||||
.required(data.placeholderText, name: "placeholderText", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.titleSuffix = data.titleSuffix
|
||||
self.thumbnailSuffix = validation.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
|
||||
self.cornerText = validation.unused(data.cornerText, "cornerText", source: source)
|
||||
self.externalUrl = validation.unexpected(data.externalUrl, name: "externalUrl", source: source)
|
||||
self.thumbnailSuffix = log.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
|
||||
self.cornerText = log.unused(data.cornerText, "cornerText", source: source)
|
||||
self.externalUrl = log.unexpected(data.externalUrl, name: "externalUrl", source: source)
|
||||
|
||||
guard isComplete else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata, with context: Context) {
|
||||
let validation = context.validation
|
||||
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata) {
|
||||
// Go through all elements and check them for completeness
|
||||
// In the end, check that all required elements are present
|
||||
var isComplete = true
|
||||
@ -171,20 +176,21 @@ extension Element.LocalizedMetadata {
|
||||
isComplete = false
|
||||
}
|
||||
self.language = parent.language
|
||||
self.title = validation
|
||||
self.title = log
|
||||
.required(data.title, name: "title", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.subtitle = data.subtitle
|
||||
self.description = data.description
|
||||
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
|
||||
self.linkPreviewImage = validation
|
||||
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source) ?? ""
|
||||
self.linkPreviewImage = log
|
||||
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
|
||||
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
|
||||
self.linkPreviewDescription = validation
|
||||
self.linkPreviewDescription = log
|
||||
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.moreLinkText = validation.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
|
||||
self.moreLinkText = log.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
|
||||
self.backLinkText = data.backLinkText ?? parent.backLinkText
|
||||
self.parentBackLinkText = parent.backLinkText
|
||||
self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle
|
||||
self.placeholderText = data.placeholderText ?? parent.placeholderText
|
||||
self.titleSuffix = data.titleSuffix
|
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?
|
||||
|
||||
/**
|
||||
Indicate that no header should be generated automatically.
|
||||
|
||||
This option assumes that custom header code is present in the page source files
|
||||
- Note: If not specified, this property defaults to `false`.
|
||||
*/
|
||||
let useCustomHeader: Bool?
|
||||
|
||||
/**
|
||||
The localized metadata for each language.
|
||||
*/
|
||||
@ -122,7 +130,8 @@ extension GenericMetadata: Codable {
|
||||
.thumbnailStyle,
|
||||
.useManualSorting,
|
||||
.overviewItemCount,
|
||||
.languages
|
||||
.useCustomHeader,
|
||||
.languages,
|
||||
]
|
||||
}
|
||||
|
||||
@ -136,15 +145,16 @@ extension GenericMetadata {
|
||||
/**
|
||||
Decode metadata in a folder.
|
||||
|
||||
- Parameter data: The binary data of the metadata file.
|
||||
- Parameter source: The path to the metadata file, relative to the source root
|
||||
- Parameter context: The context for the element (validation, file access, etc.)
|
||||
- Note: The decoding routine also checks for unknown properties, and writes them to the output.
|
||||
- Note: Uses global objects
|
||||
*/
|
||||
init?(source: String, with context: Context) throws {
|
||||
guard let data = try context.fileSystem.loadDataContent(inputPath: source) else {
|
||||
init?(source: String) {
|
||||
guard let data = files.dataOfOptionalFile(atPath: source, source: source) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let knownKeys = GenericMetadata.knownKeys
|
||||
@ -154,20 +164,21 @@ extension GenericMetadata {
|
||||
// Only one key means we are decoding the generic metadata
|
||||
guard keys.count > 1 else {
|
||||
if !knownKeys.contains(key.stringValue) {
|
||||
context.validation.unknown(property: key.stringValue, source: source)
|
||||
log.unknown(property: key.stringValue, source: source)
|
||||
}
|
||||
return key
|
||||
}
|
||||
// Two levels means we're decoding the localized metadata
|
||||
if !knownLocalizedKeys.contains(key.stringValue) {
|
||||
context.validation.unknown(property: key.stringValue, source: source)
|
||||
log.unknown(property: key.stringValue, source: source)
|
||||
}
|
||||
return key
|
||||
}
|
||||
do {
|
||||
self = try decoder.decode(from: data)
|
||||
} catch {
|
||||
context.validation.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
|
||||
print("Here \(data)")
|
||||
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
@ -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!
|
||||
}
|
||||
|
||||
/**
|
||||
Insert the new content before the last occurence of the specified separator.
|
||||
|
||||
If the separator does not appear in the string, then the new content is simply appended.
|
||||
*/
|
||||
func insert(_ content: String, beforeLast separator: String) -> String {
|
||||
let parts = components(separatedBy: separator)
|
||||
guard parts.count > 1 else {
|
||||
return self + content
|
||||
}
|
||||
return parts.dropLast().joined(separator: separator) + content + separator + parts.last!
|
||||
}
|
||||
|
||||
@ -53,3 +61,10 @@ extension Substring {
|
||||
.components(separatedBy: end).first!
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
|
||||
func createFolderAndWrite(to url: URL) throws {
|
||||
try data(using: .utf8)!.createFolderAndWrite(to: url)
|
||||
}
|
||||
}
|
||||
|
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
|
||||
|
||||
final class ErrorOutput {
|
||||
final class ValidationLog {
|
||||
|
||||
private enum LogLevel: String {
|
||||
case error = "ERROR"
|
||||
@ -138,7 +138,7 @@ final class ErrorOutput {
|
||||
guard let string = string else {
|
||||
return nil
|
||||
}
|
||||
guard let date = ErrorOutput.metadataDate.date(from: string) else {
|
||||
guard let date = ValidationLog.metadataDate.date(from: string) else {
|
||||
add(warning: "Invalid date string '\(string)' for property '\(property)'", source: source)
|
||||
return nil
|
||||
}
|
@ -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
|
||||
|
||||
private struct AboveRootDummy: SiteElement {
|
||||
var sortIndex: Int? { nil }
|
||||
|
||||
var sortDate: Date? { nil }
|
||||
|
||||
var path: String { "" }
|
||||
|
||||
let inputFolder: URL
|
||||
|
||||
func title(for language: String) -> String { "" }
|
||||
|
||||
func cornerText(for language: String) -> String? { nil }
|
||||
|
||||
var elements: [SiteElement] { [] }
|
||||
}
|
||||
|
||||
struct IndexPageGenerator {
|
||||
|
||||
private let factory: LocalizedSiteTemplate
|
||||
@ -25,41 +9,34 @@ struct IndexPageGenerator {
|
||||
}
|
||||
|
||||
func generate(
|
||||
site: Site,
|
||||
site: Element,
|
||||
language: String,
|
||||
languageButton: String?,
|
||||
sectionItemCount: Int,
|
||||
to url: URL) throws {
|
||||
to url: URL) {
|
||||
let localized = site.localized(for: language)
|
||||
|
||||
var content = [PageTemplate.Key : String]()
|
||||
content[.head] = try makeHead(site: site, language: language)
|
||||
content[.topBar] = factory.topBar.generate(section: nil, languageButton: languageButton)
|
||||
content[.head] = factory.pageHead.generate(page: site, language: language)
|
||||
content[.topBar] = factory.topBar.generate(sectionUrl: nil, languageButton: languageButton)
|
||||
content[.contentClass] = "overview"
|
||||
content[.header] = makeHeader(localized: localized)
|
||||
let sections = site.elements.compactMap { $0 as? Section }
|
||||
content[.content] = try factory.overviewSection.generate(
|
||||
sections: sections,
|
||||
content[.content] = factory.overviewSection.generate(
|
||||
sections: site.elements,
|
||||
in: site,
|
||||
language: language,
|
||||
sectionItemCount: sectionItemCount)
|
||||
content[.footer] = try site.customFooterContent()
|
||||
try factory.page.generate(content, to: url)
|
||||
content[.footer] = site.customFooterContent()
|
||||
guard factory.page.generate(content, to: url) else {
|
||||
return
|
||||
}
|
||||
log.add(info: "Generated \(url.lastPathComponent)", source: site.path)
|
||||
}
|
||||
|
||||
private func makeHead(site: Site, language: String) throws -> String {
|
||||
let localized = site.localized(for: language)
|
||||
return try factory.pageHead.generate(page: PageHeadInfo(
|
||||
author: site.metadata.author,
|
||||
linkPreviewTitle: localized.linkPreviewTitle,
|
||||
linkPreviewDescription: localized.linkPreviewDescription,
|
||||
linkPreviewImage: site.linkPreviewImage(for: language),
|
||||
customHeadContent: try site.customHeadContent()))
|
||||
}
|
||||
|
||||
private func makeHeader(localized: Site.LocalizedMetadata) -> String {
|
||||
private func makeHeader(localized: Element.LocalizedMetadata) -> String {
|
||||
var content = [HeaderKey : String]()
|
||||
content[.title] = localized.title
|
||||
#warning("Add title suffix")
|
||||
content[.subtitle] = localized.subtitle
|
||||
content[.titleText] = localized.description
|
||||
return factory.factory.centeredHeader.generate(content)
|
||||
|
@ -9,31 +9,18 @@ struct PageContentGenerator {
|
||||
|
||||
private let factory: TemplateFactory
|
||||
|
||||
private let files: FileProcessor
|
||||
|
||||
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
|
||||
|
||||
init(factory: TemplateFactory, files: FileProcessor) {
|
||||
init(factory: TemplateFactory) {
|
||||
self.factory = factory
|
||||
self.files = files
|
||||
}
|
||||
|
||||
func generate(page: Page, language: String, at url: URL) throws -> String {
|
||||
var errorToThrow: Error? = nil
|
||||
|
||||
let content = try wrap(.missingPage(page: url.path, language: language)) {
|
||||
try String(contentsOf: url)
|
||||
}
|
||||
func generate(page: Element, language: String, content: String) -> String {
|
||||
|
||||
var hasCodeContent = false
|
||||
|
||||
let imageModifier = Modifier(target: .images) { html, markdown in
|
||||
do {
|
||||
return try processMarkdownImage(markdown: markdown, html: html, page: page)
|
||||
} catch {
|
||||
errorToThrow = error
|
||||
return ""
|
||||
}
|
||||
processMarkdownImage(markdown: markdown, html: html, page: page)
|
||||
}
|
||||
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
|
||||
if markdown.starts(with: "```swift") {
|
||||
@ -51,17 +38,13 @@ struct PageContentGenerator {
|
||||
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier])
|
||||
|
||||
if hasCodeContent {
|
||||
#warning("Automatically add hljs hightlighting if code samples are found")
|
||||
#warning("Automatically add hljs highlighting if code samples are found")
|
||||
}
|
||||
|
||||
let result = parser.html(from: content)
|
||||
if let error = errorToThrow {
|
||||
throw error
|
||||
}
|
||||
return result
|
||||
return parser.html(from: content)
|
||||
}
|
||||
|
||||
private func processMarkdownImage(markdown: Substring, html: String, page: Page) throws -> String {
|
||||
private func processMarkdownImage(markdown: Substring, html: String, page: Element) -> String {
|
||||
// Split the markdown ![alt](file "title")
|
||||
// For images: ![left_title](file "right_title")
|
||||
// For videos: ![option...](file)
|
||||
@ -72,27 +55,27 @@ struct PageContentGenerator {
|
||||
let alt = markdown.between("[", and: "]").nonEmpty
|
||||
|
||||
let fileExtension = file.lastComponentAfter(".").lowercased()
|
||||
switch files.mediaType(forExtension: fileExtension) {
|
||||
switch MediaType(fileExtension: fileExtension) {
|
||||
case .image:
|
||||
return try handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
|
||||
return handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
|
||||
case .video:
|
||||
return try handleVideo(page: page, file: file, optionString: alt)
|
||||
return handleVideo(page: page, file: file, optionString: alt)
|
||||
case .file:
|
||||
if fileExtension == "svg" {
|
||||
return try handleSvg(page: page, file: file)
|
||||
return handleSvg(page: page, file: file)
|
||||
}
|
||||
return try handleFile(page: page, file: file, fileExtension: fileExtension)
|
||||
return handleFile(page: page, file: file, fileExtension: fileExtension)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleImage(page: Page, file: String, rightTitle: String?, leftTitle: String?) throws -> String {
|
||||
private func handleImage(page: Element, file: String, rightTitle: String?, leftTitle: String?) -> String {
|
||||
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
|
||||
let size = try files.requireImage(source: imagePath, destination: imagePath, width: pageImageWidth)
|
||||
let size = files.requireImage(source: imagePath, destination: imagePath, width: pageImageWidth)
|
||||
|
||||
let imagePath2x = imagePath.insert("@2x", beforeLast: ".")
|
||||
let file2x = file.insert("@2x", beforeLast: ".")
|
||||
try files.requireImage(source: imagePath, destination: imagePath2x, width: 2 * pageImageWidth)
|
||||
files.requireImage(source: imagePath, destination: imagePath2x, width: 2 * pageImageWidth)
|
||||
|
||||
let content: [PageImageTemplate.Key : String] = [
|
||||
.image: file,
|
||||
@ -104,7 +87,7 @@ struct PageContentGenerator {
|
||||
return factory.image.generate(content)
|
||||
}
|
||||
|
||||
private func handleVideo(page: Page, file: String, optionString: String?) throws -> String {
|
||||
private func handleVideo(page: Element, file: String, optionString: String?) -> String {
|
||||
let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in
|
||||
string.components(separatedBy: " ").compactMap { optionText in
|
||||
guard let optionText = optionText.trimmed.nonEmpty else {
|
||||
@ -125,7 +108,7 @@ struct PageContentGenerator {
|
||||
return factory.video.generate(sources: sources, options: options)
|
||||
}
|
||||
|
||||
private func handleSvg(page: Page, file: String) throws -> String {
|
||||
private func handleSvg(page: Element, file: String) -> String {
|
||||
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
files.require(file: imagePath)
|
||||
|
||||
@ -136,7 +119,7 @@ struct PageContentGenerator {
|
||||
"""
|
||||
}
|
||||
|
||||
private func handleFile(page: Page, file: String, fileExtension: String) throws -> String {
|
||||
private func handleFile(page: Element, file: String, fileExtension: String) -> String {
|
||||
#warning("Handle other files in markdown")
|
||||
print("[WARN] Unhandled file \(file) with extension \(fileExtension)")
|
||||
return ""
|
||||
|
@ -4,65 +4,53 @@ struct OverviewPageGenerator {
|
||||
|
||||
private let factory: LocalizedSiteTemplate
|
||||
|
||||
let outputFolder: URL
|
||||
|
||||
init(factory: LocalizedSiteTemplate, files: FileProcessor) {
|
||||
init(factory: LocalizedSiteTemplate) {
|
||||
self.factory = factory
|
||||
self.outputFolder = files.outputFolder
|
||||
}
|
||||
|
||||
func generate(
|
||||
section: Section,
|
||||
section: Element,
|
||||
language: String,
|
||||
backText: String?) throws {
|
||||
let url = outputFolder.appendingPathComponent(section.localizedPath(for: language))
|
||||
backText: String?) {
|
||||
let path = section.localizedPath(for: language)
|
||||
let url = files.urlInOutputFolder(path)
|
||||
|
||||
let metadata = section.localized(for: language)
|
||||
|
||||
var content = [PageTemplate.Key : String]()
|
||||
content[.head] = try makeHead(section: section, language: language)
|
||||
content[.head] = factory.pageHead.generate(page: section, language: language)
|
||||
let languageButton = section.nextLanguage(for: language)
|
||||
content[.topBar] = factory.topBar.generate(
|
||||
section: section.sectionId,
|
||||
sectionUrl: section.sectionUrl(for: language),
|
||||
languageButton: languageButton)
|
||||
content[.contentClass] = "overview"
|
||||
content[.header] = makeHeader(metadata: metadata, language: language, backText: backText)
|
||||
content[.content] = try makeContent(section: section, language: language)
|
||||
content[.footer] = try section.customFooterContent()
|
||||
try factory.page.generate(content, to: url)
|
||||
content[.content] = makeContent(section: section, language: language)
|
||||
content[.footer] = section.customFooterContent()
|
||||
guard factory.page.generate(content, to: url) else {
|
||||
return
|
||||
}
|
||||
log.add(info: "Generated \(path)", source: section.path)
|
||||
}
|
||||
|
||||
private func makeContent(section: Section, language: String) throws -> String {
|
||||
private func makeContent(section: Element, language: String) -> String {
|
||||
if section.hasNestingElements {
|
||||
let sections = section.elements.compactMap { $0 as? Section }
|
||||
return try factory.overviewSection.generate(
|
||||
sections: sections,
|
||||
return factory.overviewSection.generate(
|
||||
sections: section.elements,
|
||||
in: section,
|
||||
language: language,
|
||||
sectionItemCount: section.metadata.sectionOverviewItemCount)
|
||||
sectionItemCount: section.overviewItemCount)
|
||||
} else {
|
||||
return try factory.overviewSection.generate(section: section, language: language)
|
||||
return factory.overviewSection.generate(section: section, language: language)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHead(section: Section, language: String) throws -> String {
|
||||
let localized = section.localized(for: language)
|
||||
|
||||
let image = section.linkPreviewImage(for: language)
|
||||
let info = PageHeadInfo(
|
||||
author: factory.author,
|
||||
linkPreviewTitle: localized.title,
|
||||
linkPreviewDescription: localized.linkPreviewDescription,
|
||||
linkPreviewImage: image,
|
||||
customHeadContent: try section.customHeadContent())
|
||||
return try factory.pageHead.generate(page: info)
|
||||
}
|
||||
|
||||
private func makeHeader(metadata: Section.LocalizedMetadata,
|
||||
private func makeHeader(metadata: Element.LocalizedMetadata,
|
||||
language: String,
|
||||
backText: String?) -> String {
|
||||
var content = [HeaderKey : String]()
|
||||
content[.title] = metadata.title
|
||||
#warning("Add title suffix")
|
||||
content[.subtitle] = metadata.subtitle
|
||||
content[.titleText] = metadata.description
|
||||
content[.backLink] = backText.unwrapped { factory.makeBackLink(text: $0, language: language) }
|
||||
|
@ -6,19 +6,16 @@ struct OverviewSectionGenerator {
|
||||
|
||||
private let singleSectionsTemplate: OverviewSectionCleanTemplate
|
||||
|
||||
let files: FileProcessor
|
||||
|
||||
private let generator: ThumbnailListGenerator
|
||||
|
||||
init(factory: TemplateFactory, files: FileProcessor) {
|
||||
init(factory: TemplateFactory) {
|
||||
self.multipleSectionsTemplate = factory.overviewSection
|
||||
self.singleSectionsTemplate = factory.overviewSectionClean
|
||||
self.files = files
|
||||
self.generator = ThumbnailListGenerator(factory: factory, files: files)
|
||||
self.generator = ThumbnailListGenerator(factory: factory)
|
||||
}
|
||||
|
||||
func generate(sections: [Section], in parent: SiteElement, language: String, sectionItemCount: Int) throws -> String {
|
||||
try sections.map { section in
|
||||
func generate(sections: [Element], in parent: Element, language: String, sectionItemCount: Int) -> String {
|
||||
sections.map { section in
|
||||
let metadata = section.localized(for: language)
|
||||
let fullUrl = section.fullPageUrl(for: language)
|
||||
let relativeUrl = parent.relativePathToFileWithPath(fullUrl)
|
||||
@ -26,44 +23,31 @@ struct OverviewSectionGenerator {
|
||||
var content = [OverviewSectionTemplate.Key : String]()
|
||||
content[.url] = relativeUrl
|
||||
content[.title] = metadata.title
|
||||
content[.items] = try sectionContent(section: section, in: parent, language: language, shownItemCount: sectionItemCount)
|
||||
content[.more] = metadata.moreLinkTitle
|
||||
content[.items] = sectionContent(section: section, in: parent, language: language, shownItemCount: sectionItemCount)
|
||||
content[.more] = metadata.moreLinkText
|
||||
|
||||
return multipleSectionsTemplate.generate(content)
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func generate(section: Section, language: String) throws -> String {
|
||||
func generate(section: Element, language: String) -> String {
|
||||
var content = [OverviewSectionCleanTemplate.Key : String]()
|
||||
content[.items] = try sectionContent(section: section, in: section, language: language, shownItemCount: nil)
|
||||
content[.items] = sectionContent(section: section, in: section, language: language, shownItemCount: nil)
|
||||
return singleSectionsTemplate.generate(content)
|
||||
}
|
||||
|
||||
private func sectionContent(section: Section, in parent: SiteElement, language: String, shownItemCount: Int?) throws -> String {
|
||||
let sectionItems: [SiteElement]
|
||||
private func sectionContent(section: Element, in parent: Element, language: String, shownItemCount: Int?) -> String {
|
||||
let sectionItems: [Element]
|
||||
if let shownItemCount = shownItemCount {
|
||||
sectionItems = Array(section.sortedItems.prefix(shownItemCount))
|
||||
} else {
|
||||
sectionItems = section.sortedItems
|
||||
}
|
||||
|
||||
let items: [ThumbnailInfo] = sectionItems.map { item in
|
||||
#warning("Check if page exists for the language")
|
||||
let fullPageUrl = item.fullPageUrl(for: language)
|
||||
let relativePageUrl = parent.relativePathToFileWithPath(fullPageUrl)
|
||||
let fullThumbnailPath = item.thumbnailFilePath(for: language)
|
||||
let relativeImageUrl = parent.relativePathToFileWithPath(fullThumbnailPath)
|
||||
|
||||
return ThumbnailInfo(
|
||||
url: relativePageUrl,
|
||||
imageFilePath: fullThumbnailPath,
|
||||
imageHtmlUrl: relativeImageUrl,
|
||||
title: item.title(for: language),
|
||||
cornerText: item.cornerText(for: language))
|
||||
}
|
||||
return try generator.generateContent(
|
||||
items: items,
|
||||
style: section.metadata.thumbnailStyle)
|
||||
return generator.generateContent(
|
||||
items: sectionItems,
|
||||
parent: parent,
|
||||
language: language,
|
||||
style: section.thumbnailStyle)
|
||||
}
|
||||
}
|
||||
|
@ -12,70 +12,67 @@ struct PageGenerator {
|
||||
|
||||
private let factory: LocalizedSiteTemplate
|
||||
|
||||
private let files: FileProcessor
|
||||
|
||||
init(factory: LocalizedSiteTemplate, files: FileProcessor) {
|
||||
init(factory: LocalizedSiteTemplate) {
|
||||
self.factory = factory
|
||||
self.files = files
|
||||
}
|
||||
|
||||
func generate(page: Page, language: String, backText: String, nextPage: NavigationLink?, previousPage: NavigationLink?) throws {
|
||||
func generate(page: Element, language: String, backText: String, nextPage: NavigationLink?, previousPage: NavigationLink?) {
|
||||
guard !page.isExternalPage else {
|
||||
return
|
||||
}
|
||||
guard !page.metadata.isDraft else {
|
||||
guard page.state != .draft else {
|
||||
return
|
||||
}
|
||||
let path = page.fullPageUrl(for: language)
|
||||
let inputContentUrl = page.inputFolder.appendingPathComponent("\(language).md")
|
||||
let inputContentPath = page.path + "/\(language).md"
|
||||
#warning("Make prev and next navigation relative")
|
||||
let metadata = page.localized(for: language)
|
||||
let nextLanguage = page.nextLanguage(for: language)
|
||||
var content = [PageTemplate.Key : String]()
|
||||
content[.head] = try makeHead(page: page, language: language)
|
||||
content[.topBar] = factory.topBar.generate(section: page.sectionId, languageButton: nextLanguage)
|
||||
content[.head] = factory.pageHead.generate(page: page, language: language)
|
||||
let sectionUrl = page.sectionUrl(for: language)
|
||||
content[.topBar] = factory.topBar.generate(sectionUrl: sectionUrl, languageButton: nextLanguage)
|
||||
content[.contentClass] = "content"
|
||||
if !page.metadata.useCustomHeader {
|
||||
content[.header] = makeHeader(page: page.metadata, metadata: metadata, language: language, backText: backText)
|
||||
if !page.useCustomHeader {
|
||||
content[.header] = makeHeader(page: page, metadata: metadata, language: language, backText: backText)
|
||||
}
|
||||
content[.content] = try makeContent(page: page, language: language, url: inputContentUrl)
|
||||
let pageContent = makeContent(page: page, language: language, path: inputContentPath)
|
||||
content[.content] = pageContent ?? factory.placeholder
|
||||
content[.previousPageLinkText] = previousPage.unwrapped { factory.makePrevText($0.text) }
|
||||
content[.previousPageUrl] = previousPage?.link
|
||||
content[.nextPageLinkText] = nextPage.unwrapped { factory.makeNextText($0.text) }
|
||||
content[.nextPageUrl] = nextPage?.link
|
||||
content[.footer] = try page.customFooterContent()
|
||||
content[.footer] = page.customFooterContent()
|
||||
|
||||
let url = files.outputFolder.appendingPathComponent(path)
|
||||
try factory.page.generate(content, to: url)
|
||||
}
|
||||
|
||||
private func makeContent(page: Page, language: String, url: URL) throws -> String {
|
||||
guard url.exists else {
|
||||
print("Generated empty page \(page.path)")
|
||||
return factory.placeholder
|
||||
let url = files.urlInOutputFolder(path)
|
||||
guard factory.page.generate(content, to: url) else {
|
||||
return
|
||||
}
|
||||
print("Generated page \(page.path)")
|
||||
return try PageContentGenerator(factory: factory.factory, files: files)
|
||||
.generate(page: page, language: language, at: url)
|
||||
log.add(info: "Generated \(pageContent == nil ? "empty page " : "")\(path)", source: page.path)
|
||||
}
|
||||
|
||||
private func makeHead(page: Page, language: String) throws -> String {
|
||||
let metadata = page.localized(for: language)
|
||||
let info = PageHeadInfo(
|
||||
author: page.metadata.author ?? factory.author,
|
||||
linkPreviewTitle: metadata.linkPreviewTitle,
|
||||
linkPreviewDescription: metadata.linkPreviewDescription,
|
||||
linkPreviewImage: page.linkPreviewImage(for: language),
|
||||
customHeadContent: try page.customHeadContent())
|
||||
return try factory.pageHead.generate(page: info)
|
||||
private func makeContent(page: Element, language: String, path: String) -> String? {
|
||||
guard let content = files.contentOfOptionalFile(atPath: path, source: page.path) else {
|
||||
return nil
|
||||
}
|
||||
return PageContentGenerator(factory: factory.factory)
|
||||
.generate(page: page, language: language, content: content)
|
||||
}
|
||||
|
||||
private func makeHeader(page: Page.Metadata, metadata: Page.LocalizedMetadata, language: String, backText: String) -> String {
|
||||
private func makeHeader(page: Element, metadata: Element.LocalizedMetadata, language: String, backText: String) -> String {
|
||||
var content = [HeaderKey : String]()
|
||||
content[.backLink] = factory.makeBackLink(text: backText, language: language)
|
||||
content[.title] = metadata.title
|
||||
if let suffix = metadata.titleSuffix {
|
||||
content[.title] = make(title: metadata.title, suffix: suffix)
|
||||
} else {
|
||||
content[.title] = metadata.title
|
||||
}
|
||||
content[.subtitle] = metadata.subtitle
|
||||
content[.date] = factory.makeDateString(start: page.date, end: page.endDate)
|
||||
return factory.factory.leftHeader.generate(content)
|
||||
}
|
||||
|
||||
private func make(title: String, suffix: String) -> String {
|
||||
"\(title)<span class=\"suffix\">\(suffix)</span>"
|
||||
}
|
||||
}
|
||||
|
@ -1,60 +1,36 @@
|
||||
import Foundation
|
||||
|
||||
protocol PageHeadInfoProvider {
|
||||
|
||||
var author: String { get }
|
||||
|
||||
var linkPreviewTitle: String { get }
|
||||
|
||||
var linkPreviewDescription: String { get }
|
||||
|
||||
var linkPreviewImage: String? { get }
|
||||
|
||||
var customHeadContent: String? { get }
|
||||
}
|
||||
|
||||
struct PageHeadInfo: PageHeadInfoProvider {
|
||||
|
||||
let author: String
|
||||
|
||||
let linkPreviewTitle: String
|
||||
|
||||
let linkPreviewDescription: String
|
||||
|
||||
let linkPreviewImage: String?
|
||||
|
||||
let customHeadContent: String?
|
||||
}
|
||||
|
||||
struct PageHeadGenerator {
|
||||
|
||||
static let linkPreviewDesiredImageWidth = 1600
|
||||
|
||||
let template: PageHeadTemplate
|
||||
|
||||
let files: FileProcessor
|
||||
|
||||
init(factory: TemplateFactory, files: FileProcessor) {
|
||||
init(factory: TemplateFactory) {
|
||||
self.template = factory.pageHead
|
||||
self.files = files
|
||||
}
|
||||
|
||||
func generate(page: PageHeadInfoProvider) throws -> String {
|
||||
func generate(page: Element, language: String) -> String {
|
||||
let metadata = page.localized(for: language)
|
||||
|
||||
var content = [PageHeadTemplate.Key : String]()
|
||||
content[.author] = page.author
|
||||
content[.title] = page.linkPreviewTitle
|
||||
content[.description] = page.linkPreviewDescription
|
||||
if let image = page.linkPreviewImage {
|
||||
content[.title] = metadata.linkPreviewTitle
|
||||
content[.description] = metadata.linkPreviewDescription
|
||||
if let image = page.linkPreviewImage(for: language) {
|
||||
// Note: Generate separate destination link for the image,
|
||||
// since we don't want a single large image for thumbnails.
|
||||
// Warning: Link preview source path must be relative to root
|
||||
let linkPreviewImagePath = image.insert("-link", beforeLast: ".")
|
||||
try files.requireImage(
|
||||
source: image,
|
||||
destination: linkPreviewImagePath,
|
||||
width: Site.linkPreviewDesiredImageWidth)
|
||||
#warning("Make link preview image path absolute")
|
||||
content[.image] = "<meta property=\"og:image\" content=\"\(linkPreviewImagePath)\" />"
|
||||
let linkPreviewImageName = image.insert("-link", beforeLast: ".")
|
||||
let sourceImagePath = page.pathRelativeToRootForContainedInputFile(image)
|
||||
let destinationImagePath = page.pathRelativeToRootForContainedInputFile(linkPreviewImageName)
|
||||
files.requireImage(
|
||||
source: sourceImagePath,
|
||||
destination: destinationImagePath,
|
||||
width: PageHeadGenerator.linkPreviewDesiredImageWidth)
|
||||
content[.image] = "<meta property=\"og:image\" content=\"\(linkPreviewImageName)\" />"
|
||||
}
|
||||
content[.customPageContent] = page.customHeadContent
|
||||
content[.customPageContent] = page.customHeadContent()
|
||||
|
||||
return template.generate(content)
|
||||
}
|
||||
|
@ -2,60 +2,45 @@ import Foundation
|
||||
|
||||
struct SiteGenerator {
|
||||
|
||||
let site: Site
|
||||
|
||||
let templates: TemplateFactory
|
||||
|
||||
private let files: FileProcessor
|
||||
|
||||
private var outputFolder: URL {
|
||||
files.outputFolder
|
||||
}
|
||||
|
||||
init(site: Site, files: FileProcessor) throws {
|
||||
self.site = site
|
||||
let templatesFolder = site.inputFolder.appendingPathComponent("templates")
|
||||
init() throws {
|
||||
let templatesFolder = files.urlInContentFolder("templates")
|
||||
self.templates = try TemplateFactory(templateFolder: templatesFolder)
|
||||
self.files = files
|
||||
}
|
||||
|
||||
func generate() throws {
|
||||
try site.metadata.languages.forEach { metadata in
|
||||
let language = metadata.languageIdentifier
|
||||
func generate(site: Element) throws {
|
||||
try site.languages.forEach { metadata in
|
||||
let language = metadata.language
|
||||
let template = try LocalizedSiteTemplate(
|
||||
factory: templates,
|
||||
language: language,
|
||||
site: site,
|
||||
files: files)
|
||||
site: site)
|
||||
|
||||
|
||||
// Generate sections
|
||||
let overviewGenerator = OverviewPageGenerator(factory: template, files: files)
|
||||
let pageGenerator = PageGenerator(factory: template, files: files)
|
||||
let backLinkText = try site.backLinkText(for: language)
|
||||
var elementsToProcess: [(element: SiteElement, backText: String?)] = site.elements.map { ($0, backLinkText) }
|
||||
while let (element, backText) = elementsToProcess.popLast() {
|
||||
if let section = element as? Section {
|
||||
try overviewGenerator.generate(
|
||||
section: section,
|
||||
let overviewGenerator = OverviewPageGenerator(factory: template)
|
||||
let pageGenerator = PageGenerator(factory: template)
|
||||
var elementsToProcess: [Element] = site.elements
|
||||
while let element = elementsToProcess.popLast() {
|
||||
// Move recursively down to all pages
|
||||
elementsToProcess.append(contentsOf: element.elements)
|
||||
|
||||
element.requiredFiles.forEach(files.require)
|
||||
|
||||
let backLinkText = element.backLinkText(for: language)
|
||||
if !element.elements.isEmpty {
|
||||
overviewGenerator.generate(
|
||||
section: element,
|
||||
language: language,
|
||||
backText: backText)
|
||||
let elementBackText = try element.backLinkText(for: language)
|
||||
let nestedElements = section.elements.map { ($0, elementBackText) }
|
||||
elementsToProcess.append(contentsOf: nestedElements)
|
||||
}
|
||||
if let page = element as? Page {
|
||||
backText: backLinkText)
|
||||
} else {
|
||||
#warning("Determine previous and next pages")
|
||||
try pageGenerator.generate(
|
||||
page: page,
|
||||
pageGenerator.generate(
|
||||
page: element,
|
||||
language: language,
|
||||
backText: backText ?? metadata.defaultBackLinkText,
|
||||
backText: backLinkText,
|
||||
nextPage: nil,
|
||||
previousPage: nil)
|
||||
for file in page.metadata.requiredFiles {
|
||||
let relativePath = page.path + "/" + file
|
||||
files.require(file: relativePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,9 +48,9 @@ struct SiteGenerator {
|
||||
|
||||
// Generate front page
|
||||
let relativeUrl = site.localizedPath(for: language)
|
||||
let indexPageUrl = outputFolder.appendingPathComponent(relativeUrl)
|
||||
let indexPageUrl = files.urlInOutputFolder(relativeUrl)
|
||||
let button = site.nextLanguage(for: language)
|
||||
try generator.generate(
|
||||
generator.generate(
|
||||
site: site,
|
||||
language: language,
|
||||
languageButton: button,
|
||||
|
@ -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
|
||||
|
||||
let files: FileProcessor
|
||||
|
||||
init(factory: TemplateFactory, files: FileProcessor) {
|
||||
init(factory: TemplateFactory) {
|
||||
self.factory = factory
|
||||
self.files = files
|
||||
}
|
||||
|
||||
func generateContent(items: [ThumbnailInfo], style: ThumbnailStyle) throws -> String {
|
||||
try items.map { try itemContent($0, style: style) }
|
||||
func generateContent(items: [Element], parent: Element, language: String, style: ThumbnailStyle) -> String {
|
||||
items.map { itemContent($0, parent: parent, language: language, style: style) }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func itemContent(_ thumbnail: ThumbnailInfo, style: ThumbnailStyle) throws -> String {
|
||||
private func itemContent(_ item: Element, parent: Element, language: String, style: ThumbnailStyle) -> String {
|
||||
let fullPageUrl = item.fullPageUrl(for: language)
|
||||
let relativePageUrl = parent.relativePathToFileWithPath(fullPageUrl)
|
||||
let fullThumbnailPath = item.thumbnailFilePath(for: language)
|
||||
let relativeImageUrl = parent.relativePathToFileWithPath(fullThumbnailPath)
|
||||
|
||||
var content = [ThumbnailKey : String]()
|
||||
content[.url] = thumbnail.url.unwrapped { "href=\"\($0)\"" }
|
||||
content[.image] = thumbnail.imageHtmlUrl
|
||||
content[.title] = thumbnail.title
|
||||
content[.image2x] = thumbnail.imageHtmlUrl.insert("@2x", beforeLast: ".")
|
||||
content[.corner] = thumbnail.cornerText.unwrapped {
|
||||
content[.url] = "href=\"\(relativePageUrl)\""
|
||||
content[.image] = relativeImageUrl
|
||||
content[.title] = item.title(for: language)
|
||||
#warning("Generate thumbnail suffix")
|
||||
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
|
||||
content[.corner] = item.cornerText(for: language).unwrapped {
|
||||
factory.largeThumbnail.makeCorner(text: $0)
|
||||
}
|
||||
|
||||
try files.requireImage(
|
||||
source: thumbnail.imageFilePath,
|
||||
destination: thumbnail.imageFilePath,
|
||||
files.requireImage(
|
||||
source: fullThumbnailPath,
|
||||
destination: fullThumbnailPath,
|
||||
width: style.width,
|
||||
desiredHeight: style.height,
|
||||
createDoubleVersion: true)
|
||||
|
||||
return try factory.thumbnail(style: style).generate(content, shouldIndent: false)
|
||||
return factory.thumbnail(style: style).generate(content, shouldIndent: false)
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
||||
func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) throws -> String
|
||||
func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) -> String
|
||||
}
|
||||
|
||||
enum ThumbnailKey: String, CaseIterable {
|
||||
|
@ -31,27 +31,14 @@ struct LocalizedSiteTemplate {
|
||||
topBar.language
|
||||
}
|
||||
|
||||
// MARK: Thumbnails
|
||||
|
||||
func thumbnail(style: ThumbnailStyle) -> ThumbnailTemplate {
|
||||
switch style {
|
||||
case .large:
|
||||
return factory.largeThumbnail
|
||||
case .square:
|
||||
return factory.squareThumbnail
|
||||
case .small:
|
||||
return factory.smallThumbnail
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Pages
|
||||
|
||||
var page: PageTemplate {
|
||||
factory.page
|
||||
}
|
||||
|
||||
init(factory: TemplateFactory, language: String, site: Site, files: FileProcessor) throws {
|
||||
self.author = site.metadata.author
|
||||
init(factory: TemplateFactory, language: String, site: Element) throws {
|
||||
self.author = site.author
|
||||
self.factory = factory
|
||||
|
||||
let df = DateFormatter()
|
||||
@ -70,28 +57,23 @@ struct LocalizedSiteTemplate {
|
||||
df3.locale = Locale(identifier: language)
|
||||
self.day = df3
|
||||
|
||||
let metadata = site.localized(for: language)
|
||||
|
||||
let sections = site.elements.map {
|
||||
PrefilledTopBarTemplate.SectionInfo(
|
||||
id: $0.sectionId,
|
||||
name: $0.title(for: language),
|
||||
url: "\($0.path)/\(language).html")
|
||||
}
|
||||
|
||||
let metadata = site.localized(for: language)
|
||||
|
||||
let title = site.metadata.topBarTitle ?? metadata.linkPreviewTitle
|
||||
|
||||
self.topBar = try .init(
|
||||
template: factory.topBar,
|
||||
language: language,
|
||||
sections: sections,
|
||||
topBarWebsiteTitle: title)
|
||||
topBarWebsiteTitle: site.topBarTitle)
|
||||
self.pageHead = PageHeadGenerator(
|
||||
factory: factory,
|
||||
files: files)
|
||||
factory: factory)
|
||||
self.overviewSection = OverviewSectionGenerator(
|
||||
factory: factory,
|
||||
files: files)
|
||||
factory: factory)
|
||||
|
||||
self.placeholder = factory.placeholder.generate([
|
||||
.title: metadata.placeholderTitle,
|
||||
@ -108,8 +90,6 @@ struct LocalizedSiteTemplate {
|
||||
return backNavigation.generate(content)
|
||||
}
|
||||
|
||||
#warning("Move HTML code to single location")
|
||||
|
||||
func makePrevText(_ text: String) -> String {
|
||||
"<span class=\"icon-back\"></span>\(text)"
|
||||
}
|
||||
@ -118,7 +98,10 @@ struct LocalizedSiteTemplate {
|
||||
"\(text)<span class=\"icon-next\"></span>"
|
||||
}
|
||||
|
||||
func makeDateString(start: Date, end: Date?) -> String {
|
||||
func makeDateString(start: Date?, end: Date?) -> String {
|
||||
guard let start = start else {
|
||||
return ""
|
||||
}
|
||||
guard let end = end else {
|
||||
return fullDateFormatter.string(from: start)
|
||||
}
|
||||
|
@ -17,19 +17,19 @@ struct PrefilledTopBarTemplate {
|
||||
self.topBarWebsiteTitle = topBarWebsiteTitle
|
||||
}
|
||||
|
||||
func generate(section: String?, languageButton: String?) -> String {
|
||||
func generate(sectionUrl: String?, languageButton: String?) -> String {
|
||||
var content = [TopBarTemplate.Key : String]()
|
||||
content[.title] = topBarWebsiteTitle
|
||||
content[.titleLink] = topBarWebsiteTitle(language: language)
|
||||
content[.elements] = elements(activeSection: section)
|
||||
content[.elements] = elements(activeSectionUrl: sectionUrl)
|
||||
content[.languageButton] = languageButton.unwrapped(topBarLanguageButton) ?? ""
|
||||
return topBar.generate(content)
|
||||
}
|
||||
|
||||
private func elements(activeSection: String?) -> String {
|
||||
private func elements(activeSectionUrl: String?) -> String {
|
||||
sections
|
||||
.map {
|
||||
topBarNavigationLink(url: $0.url, text: $0.name, isActive: activeSection == $0.id)
|
||||
topBarNavigationLink(url: $0.url, text: $0.name, isActive: activeSectionUrl == $0.url)
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
@ -49,8 +49,6 @@ struct PrefilledTopBarTemplate {
|
||||
|
||||
struct SectionInfo {
|
||||
|
||||
let id: String
|
||||
|
||||
let name: String
|
||||
|
||||
let url: String
|
||||
|
@ -20,17 +20,13 @@ extension Template {
|
||||
}
|
||||
|
||||
init(from url: URL) throws {
|
||||
let raw = try wrap(.failedToLoadTemplate(url.lastPathComponent)) {
|
||||
try String(contentsOf: url)
|
||||
}
|
||||
let raw = try String(contentsOf: url)
|
||||
self.init(raw: raw)
|
||||
}
|
||||
|
||||
func generate(_ content: [Key : String], to url: URL) throws {
|
||||
func generate(_ content: [Key : String], to url: URL) -> Bool {
|
||||
let content = generate(content)
|
||||
try wrap(.failedToWriteFile(url.path)) {
|
||||
try content.createFolderAndWrite(to: url)
|
||||
}
|
||||
return files.write(content, to: url)
|
||||
}
|
||||
|
||||
func generate(_ content: [Key : String], shouldIndent: Bool = false) -> String {
|
||||
|
@ -1,13 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace")
|
||||
let outputDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace/Site")
|
||||
private let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace")
|
||||
private let outputDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace/Site")
|
||||
|
||||
let context = Context(inputFolder: contentDirectory, outputFolder: outputDirectory)
|
||||
let log = ValidationLog()
|
||||
let files = FileSystem(in: contentDirectory, to: outputDirectory)
|
||||
|
||||
let siteData: Element
|
||||
private let siteData: Element
|
||||
do {
|
||||
guard let element = try Element(atRoot: contentDirectory, with: context) else {
|
||||
guard let element = try Element(atRoot: contentDirectory) else {
|
||||
exit(0)
|
||||
}
|
||||
siteData = element
|
||||
@ -17,26 +18,13 @@ do {
|
||||
}
|
||||
|
||||
siteData.printTree()
|
||||
context.fileSystem.printAllTouchedFiles()
|
||||
context.fileSystem.didGenerateAllFiles()
|
||||
|
||||
exit(0)
|
||||
|
||||
let files = FileProcessor(
|
||||
inputFolder: contentDirectory, outputFolder: outputDirectory)
|
||||
|
||||
// 1: Load all site content
|
||||
guard let site = try Site(folder: contentDirectory) else {
|
||||
exit(0)
|
||||
}
|
||||
// site.printContents()
|
||||
let siteGenerator = try SiteGenerator(site: site, files: files)
|
||||
try siteGenerator.generate()
|
||||
private let siteGenerator = try SiteGenerator()
|
||||
try siteGenerator.generate(site: siteData)
|
||||
|
||||
print("Pages generated")
|
||||
try files.createImages()
|
||||
files.createImages()
|
||||
print("Images generated")
|
||||
try files.copyRequiredFiles()
|
||||
files.copyRequiredFiles()
|
||||
print("Required files copied")
|
||||
|
||||
#warning("Check that all metadata for each language is present")
|
||||
files.writeHashes()
|
||||
|
Loading…
Reference in New Issue
Block a user