diff --git a/WebsiteGenerator.xcodeproj/project.pbxproj b/WebsiteGenerator.xcodeproj/project.pbxproj index d4b00f6..0ce3aee 100644 --- a/WebsiteGenerator.xcodeproj/project.pbxproj +++ b/WebsiteGenerator.xcodeproj/project.pbxproj @@ -8,6 +8,49 @@ /* 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 /* ImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E8797289EA42C00E51191 /* ImageProcessor.swift */; }; + E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879A289EE02F00E51191 /* Optional+Extensions.swift */; }; + E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */; }; + E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */; }; + E22E87A4289F0C7000E51191 /* SiteGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87A3289F0C7000E51191 /* SiteGenerator.swift */; }; + E22E87A8289F0E7B00E51191 /* PageGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87A7289F0E7B00E51191 /* PageGenerator.swift */; }; + E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */; }; + E22E87AC289F1D3700E51191 /* Template.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AB289F1D3700E51191 /* Template.swift */; }; + E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AD289F1E0000E51191 /* String+Extensions.swift */; }; + E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */; }; + E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B1289F296700E51191 /* ThumbnailInfo.swift */; }; + E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B5289FF67B00E51191 /* Metadata.swift */; }; + E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */; }; + E2C5A5D528A0223C00102A25 /* OverviewPageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D428A0223C00102A25 /* OverviewPageTemplate.swift */; }; + E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D628A022C500102A25 /* TemplateFactory.swift */; }; + E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */; }; + E2C5A5DB28A02F9000102A25 /* TopBarTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */; }; + E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5DC28A036BE00102A25 /* OverviewSectionTemplate.swift */; }; + E2C5A5E128A0373300102A25 /* ThumbnailTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */; }; + E2C5A5E328A037F900102A25 /* ContentPageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5E228A037F900102A25 /* ContentPageTemplate.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 */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -25,6 +68,48 @@ /* Begin PBXFileReference section */ E22E875F289D84C300E51191 /* WebsiteGenerator */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = WebsiteGenerator; sourceTree = BUILT_PRODUCTS_DIR; }; E22E8762289D84C300E51191 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + E22E8769289D84FD00E51191 /* Section+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+Metadata.swift"; sourceTree = ""; }; + E22E876B289D855D00E51191 /* ThumbnailStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailStyle.swift; sourceTree = ""; }; + E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+LocalizedMetadata.swift"; sourceTree = ""; }; + E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexPageGenerator.swift; sourceTree = ""; }; + E22E8777289DA0E100E51191 /* GenerationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationError.swift; sourceTree = ""; }; + E22E8779289DA9F900E51191 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = ""; }; + E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionGenerator.swift; sourceTree = ""; }; + E22E877E289DC11F00E51191 /* Site+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+Metadata.swift"; sourceTree = ""; }; + E22E8781289DCCB600E51191 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = ""; }; + E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+LocalizedMetadata.swift"; sourceTree = ""; }; + E22E8786289DDF4C00E51191 /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = ""; }; + E22E8788289DDF5700E51191 /* Page+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Metadata.swift"; sourceTree = ""; }; + E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+LocalizedMetadata.swift"; sourceTree = ""; }; + E22E8794289E81D700E51191 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = ""; }; + E22E8797289EA42C00E51191 /* ImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessor.swift; sourceTree = ""; }; + E22E879A289EE02F00E51191 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = ""; }; + E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageGenerator.swift; sourceTree = ""; }; + E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailListGenerator.swift; sourceTree = ""; }; + E22E87A3289F0C7000E51191 /* SiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteGenerator.swift; sourceTree = ""; }; + E22E87A7289F0E7B00E51191 /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = ""; }; + E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadGenerator.swift; sourceTree = ""; }; + E22E87AB289F1D3700E51191 /* Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template.swift; sourceTree = ""; }; + E22E87AD289F1E0000E51191 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = ""; }; + E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefilledTopBarTemplate.swift; sourceTree = ""; }; + E22E87B1289F296700E51191 /* ThumbnailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailInfo.swift; sourceTree = ""; }; + E22E87B5289FF67B00E51191 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; + E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadataProvider.swift; sourceTree = ""; }; + E2C5A5D428A0223C00102A25 /* OverviewPageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageTemplate.swift; sourceTree = ""; }; + E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = ""; }; + E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadTemplate.swift; sourceTree = ""; }; + E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBarTemplate.swift; sourceTree = ""; }; + E2C5A5DC28A036BE00102A25 /* OverviewSectionTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionTemplate.swift; sourceTree = ""; }; + E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailTemplate.swift; sourceTree = ""; }; + E2C5A5E228A037F900102A25 /* ContentPageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPageTemplate.swift; sourceTree = ""; }; + E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationTemplate.swift; sourceTree = ""; }; + E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSiteTemplate.swift; sourceTree = ""; }; + E2C5A5EB28A055E900102A25 /* SiteElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteElement.swift; sourceTree = ""; }; + E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageContainer.swift; sourceTree = ""; }; + E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionCleanTemplate.swift; sourceTree = ""; }; + E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadata.swift; sourceTree = ""; }; + E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = ""; }; + E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -32,6 +117,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + E22E878C289E4A8900E51191 /* Ink in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -58,10 +144,108 @@ isa = PBXGroup; children = ( E22E8762289D84C300E51191 /* main.swift */, + E22E87A1289F0BF000E51191 /* Content */, + E22E87A2289F0C6200E51191 /* Generators */, + E2C5A5D328A0222B00102A25 /* Templates */, + E22E8799289EE02300E51191 /* Extensions */, + E22E876B289D855D00E51191 /* ThumbnailStyle.swift */, + E22E8797289EA42C00E51191 /* ImageProcessor.swift */, + E22E8777289DA0E100E51191 /* GenerationError.swift */, + E22E8794289E81D700E51191 /* FileSystem.swift */, ); path = WebsiteGenerator; sourceTree = ""; }; + E22E8799289EE02300E51191 /* Extensions */ = { + isa = PBXGroup; + children = ( + E22E879A289EE02F00E51191 /* Optional+Extensions.swift */, + E22E87AD289F1E0000E51191 /* String+Extensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + E22E87A1289F0BF000E51191 /* Content */ = { + isa = PBXGroup; + children = ( + E2C5A5EB28A055E900102A25 /* SiteElement.swift */, + E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */, + E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */, + E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */, + E22E8786289DDF4C00E51191 /* Page.swift */, + E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */, + E22E8788289DDF5700E51191 /* Page+Metadata.swift */, + E22E8781289DCCB600E51191 /* Section.swift */, + E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */, + E22E8769289D84FD00E51191 /* Section+Metadata.swift */, + E22E8779289DA9F900E51191 /* Site.swift */, + E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */, + E22E877E289DC11F00E51191 /* Site+Metadata.swift */, + E22E87B5289FF67B00E51191 /* Metadata.swift */, + ); + path = Content; + sourceTree = ""; + }; + E22E87A2289F0C6200E51191 /* Generators */ = { + isa = PBXGroup; + children = ( + E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */, + E22E87B1289F296700E51191 /* ThumbnailInfo.swift */, + E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */, + E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */, + E22E87A3289F0C7000E51191 /* SiteGenerator.swift */, + E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */, + E22E87A7289F0E7B00E51191 /* PageGenerator.swift */, + E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */, + E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */, + ); + path = Generators; + sourceTree = ""; + }; + E2C5A5D328A0222B00102A25 /* Templates */ = { + isa = PBXGroup; + children = ( + E2C5A5EA28A047B100102A25 /* Filled */, + E2C5A5E728A03E4000102A25 /* Pages */, + E2C5A5E628A03B1600102A25 /* Elements */, + E2C5A5D628A022C500102A25 /* TemplateFactory.swift */, + E22E87AB289F1D3700E51191 /* Template.swift */, + ); + path = Templates; + sourceTree = ""; + }; + E2C5A5E628A03B1600102A25 /* Elements */ = { + isa = PBXGroup; + children = ( + E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */, + E2C5A5DC28A036BE00102A25 /* OverviewSectionTemplate.swift */, + E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */, + E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */, + E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */, + E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */, + E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */, + ); + path = Elements; + sourceTree = ""; + }; + E2C5A5E728A03E4000102A25 /* Pages */ = { + isa = PBXGroup; + children = ( + E2C5A5D428A0223C00102A25 /* OverviewPageTemplate.swift */, + E2C5A5E228A037F900102A25 /* ContentPageTemplate.swift */, + ); + path = Pages; + sourceTree = ""; + }; + E2C5A5EA28A047B100102A25 /* Filled */ = { + isa = PBXGroup; + children = ( + E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */, + E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */, + ); + path = Filled; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -78,6 +262,9 @@ dependencies = ( ); name = WebsiteGenerator; + packageProductDependencies = ( + E22E878B289E4A8900E51191 /* Ink */, + ); productName = WebsiteGenerator; productReference = E22E875F289D84C300E51191 /* WebsiteGenerator */; productType = "com.apple.product-type.tool"; @@ -106,6 +293,9 @@ Base, ); mainGroup = E22E8756289D84C300E51191; + packageReferences = ( + E22E878A289E4A8900E51191 /* XCRemoteSwiftPackageReference "ink" */, + ); productRefGroup = E22E8760289D84C300E51191 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -120,7 +310,49 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */, + E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */, + E22E876E289D868100E51191 /* Site+LocalizedMetadata.swift in Sources */, + E2C5A5D528A0223C00102A25 /* OverviewPageTemplate.swift in Sources */, + E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */, + E22E8798289EA42C00E51191 /* ImageProcessor.swift in Sources */, + E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */, + E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */, + E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */, + E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */, + E22E876A289D84FD00E51191 /* Section+Metadata.swift in Sources */, + E2F8FA2028AB72D900632026 /* PlaceholderTemplate.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 */, + E22E8784289DCD5E00E51191 /* Section+LocalizedMetadata.swift in Sources */, + E22E8789289DDF5700E51191 /* Page+Metadata.swift in Sources */, + E2C5A5EC28A055E900102A25 /* SiteElement.swift in Sources */, + E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */, + E22E87A8289F0E7B00E51191 /* PageGenerator.swift in Sources */, + E2C5A5E328A037F900102A25 /* ContentPageTemplate.swift in Sources */, + E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */, + E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.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 */, + E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */, E22E8763289D84C300E51191 /* main.swift in Sources */, + E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */, + E22E877A289DA9F900E51191 /* Site.swift in Sources */, + E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */, + E22E8787289DDF4C00E51191 /* Page.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -284,6 +516,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + E22E878A289E4A8900E51191 /* XCRemoteSwiftPackageReference "ink" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/johnsundell/ink.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.5.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + E22E878B289E4A8900E51191 /* Ink */ = { + isa = XCSwiftPackageProductDependency; + package = E22E878A289E4A8900E51191 /* XCRemoteSwiftPackageReference "ink" */; + productName = Ink; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = E22E8757289D84C300E51191 /* Project object */; } diff --git a/WebsiteGenerator/Content/LanguageContainer.swift b/WebsiteGenerator/Content/LanguageContainer.swift new file mode 100644 index 0000000..0ec161a --- /dev/null +++ b/WebsiteGenerator/Content/LanguageContainer.swift @@ -0,0 +1,119 @@ +import Foundation + +protocol LanguageIdentifiable { + + var languageIdentifier: String { get } + + var title: String { get } +} + +protocol LanguageContainer { + + associatedtype LocalizedContainer: LanguageIdentifiable + + var languages: [LocalizedContainer] { get } + +} + +protocol LocalizedMetadataContainer { + + associatedtype MetadataType: LanguageContainer + + var metadata: MetadataType { get } + + func hasContent(for language: String) -> Bool +} + +// MARK: Default implementations + +extension LocalizedMetadataContainer { + + func hasContent(for language: String) -> Bool { + true + } +} + +// MARK: Extensions + +extension LocalizedMetadataContainer { + + func localized(for language: String) -> MetadataType.LocalizedContainer { + metadata.localized(for: language) + } + + /** + The localized title of the element. + + This title is used as large text in overview pages, or as the `

` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews. + */ + func title(for language: String) -> String { + localized(for: language).title + } + + + func nextLanguage(for languageIdentifier: String) -> String? { + let langs = metadata.languages.map { $0.languageIdentifier } + guard let index = langs.firstIndex(of: languageIdentifier) else { + return nil + } + for i in 1.. LocalizedContainer { + languages.first { $0.languageIdentifier == language }! + } + + /** + The localized title of the element. + + This title is used as large text in overview pages, or as the `

` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews. + */ + func title(for language: String) -> String { + localized(for: language).title + } +} + +extension LocalizedMetadataContainer where Self: SiteElement, Self.MetadataType.LocalizedContainer: LinkPreviewMetadataProvider { + + private func linkPreviewImageFileName(for language: String) -> String? { + if let fileName = localized(for: language).linkPreview?.image { + return fileName + } + // Check for the existence of a localized thumbnail + let fileName = Self.thumbnailFileNameLocalized(for: language) + if inputFolder.appendingPathComponent(fileName).exists { + return fileName + } + let defaultThumbnail = Self.defaultThumbnailFileName + if inputFolder.appendingPathComponent(defaultThumbnail).exists { + return defaultThumbnail + } + return nil + } + + func linkPreviewImage(for language: String) -> String? { + guard let fileName = linkPreviewImageFileName(for: language) else { + return nil + } + return "/\(path)/\(fileName)" + } +} diff --git a/WebsiteGenerator/Content/LinkPreviewMetadata.swift b/WebsiteGenerator/Content/LinkPreviewMetadata.swift new file mode 100644 index 0000000..e80f6f1 --- /dev/null +++ b/WebsiteGenerator/Content/LinkPreviewMetadata.swift @@ -0,0 +1,41 @@ +import Foundation + +/** + Localized configuration data for link previews of site elements. + + This struct is embedded in localized metadata and intended to be filled in the JSON source. + */ +struct LinkPreviewMetadata { + + /** + The title to use for the link preview. + + If `nil` is specified, then the localized element title is used. + */ + let title: String? + + /** + The file name of the link preview image. + - Note: The image must be located in the element folder. + - Note: If `nil` is specified, then the (localized) thumbnail is used. + */ + let image: String? + + /** + The description text for the link preview. + - Note: If `nil` is specified, then first the (localized) element subtitle is used. + If this is `nil` too, then the localized description of the element is used. + */ + let description: String? +} + +extension LinkPreviewMetadata: Codable { } + +extension LinkPreviewMetadata { + + static var initial: LinkPreviewMetadata { + .init(title: nil, + image: nil, + description: "The page description for link previews") + } +} diff --git a/WebsiteGenerator/Content/LinkPreviewMetadataProvider.swift b/WebsiteGenerator/Content/LinkPreviewMetadataProvider.swift new file mode 100644 index 0000000..26711d8 --- /dev/null +++ b/WebsiteGenerator/Content/LinkPreviewMetadataProvider.swift @@ -0,0 +1,23 @@ +import Foundation + +protocol LinkPreviewMetadataProvider { + + var linkPreview: LinkPreviewMetadata? { get } + + var title: String { get } + + var subtitle: String? { get } + + var description: String { get } +} + +extension LinkPreviewMetadataProvider { + + var linkPreviewTitle: String { + linkPreview?.title ?? title + } + + var linkPreviewDescription: String { + linkPreview?.description ?? subtitle ?? description + } +} diff --git a/WebsiteGenerator/Content/Metadata.swift b/WebsiteGenerator/Content/Metadata.swift new file mode 100644 index 0000000..e5e4399 --- /dev/null +++ b/WebsiteGenerator/Content/Metadata.swift @@ -0,0 +1,29 @@ +import Foundation + +protocol Metadata: Codable { + + static var fileName: String { get } + + static var initial: Self { get } +} + +extension Metadata { + + static func url(in folder: URL) -> URL { + folder.appendingPathComponent(fileName) + } + + static func exists(in folder: URL) -> Bool { + url(in: folder).exists + } + + init?(in folder: URL) throws { + let metadataUrl = Self.url(in: folder) + guard metadataUrl.exists else { + try Self.initial.writeJSON(to: metadataUrl) + print("Created metadata in \(folder)") + return nil + } + try self.init(decodeFrom: metadataUrl) + } +} diff --git a/WebsiteGenerator/Content/Page+LocalizedMetadata.swift b/WebsiteGenerator/Content/Page+LocalizedMetadata.swift new file mode 100644 index 0000000..4ef9505 --- /dev/null +++ b/WebsiteGenerator/Content/Page+LocalizedMetadata.swift @@ -0,0 +1,62 @@ +import Foundation + +extension Page { + + struct LocalizedMetadata { + + let id: String + + let title: String + + #warning("Generate title suffix") + let titleSuffix: String? + + let linkPreview: LinkPreviewMetadata? + + let subtitle: String? + + #warning("Generate thumbnail suffix") + let thumbnailSuffix: String? + + let cornerText: String? + + /** + The external url to use instead of automatically generating the page. + + This property can be used for links to other parts of the site, like additional services. + It can also be set to manually write a page. + */ + let externalUrl: String? + + } + +} +extension Page.LocalizedMetadata: Codable { + +} + +extension Page.LocalizedMetadata: LanguageIdentifiable { + + var languageIdentifier: String { + id + } +} + +extension Page.LocalizedMetadata { + + static var initial: Page.LocalizedMetadata { + .init(id: "en", + title: "Page title", + titleSuffix: nil, + linkPreview: .initial, + subtitle: "Some text below the title", + thumbnailSuffix: "Project", + cornerText: nil, + externalUrl: nil) + } +} + +extension Page.LocalizedMetadata: LinkPreviewMetadataProvider { + + var description: String { subtitle ?? title } +} diff --git a/WebsiteGenerator/Content/Page+Metadata.swift b/WebsiteGenerator/Content/Page+Metadata.swift new file mode 100644 index 0000000..9f90a50 --- /dev/null +++ b/WebsiteGenerator/Content/Page+Metadata.swift @@ -0,0 +1,93 @@ +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] + + #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: []) + } +} + +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 + } + + 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) + } + } + + 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) ?? [] + } +} diff --git a/WebsiteGenerator/Content/Page.swift b/WebsiteGenerator/Content/Page.swift new file mode 100644 index 0000000..4879c30 --- /dev/null +++ b/WebsiteGenerator/Content/Page.swift @@ -0,0 +1,83 @@ +import Foundation + +struct Page { + + let metadata: Metadata + + /// The input folder where the page data is stored + let inputFolder: URL + + let path: String + + init?(folder: URL, path: String) throws { + self.path = path + guard let metadata = try Metadata(in: folder) else { + return nil + } + self.inputFolder = folder + self.metadata = metadata + } +} + +extension Page { + + static let metadataDateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "dd.MM.yy" + return df + }() +} + +extension Page: SiteElement { + + var sortIndex: Int? { + metadata.sortIndex + } + + var sortDate: Date? { + metadata.date + } + + var elements: [SiteElement] { [] } + + func cornerText(for language: String) -> String? { + localized(for: language).cornerText + } + + var isExternalPage: Bool { + metadata.languages.contains { $0.externalUrl != nil } + } + + func fullPageUrl(for language: String) -> String { + localized(for: language).externalUrl ?? "\(path)/\(language).html" + } +} + +extension Page: LocalizedMetadataContainer { + + /** + Get the url of the content markdown file for a language. + + To check if the file also exists, use `existingContentUrl(for:)` + */ + func contentUrl(for language: String) -> URL { + inputFolder.appendingPathComponent("\(language).md") + } + + /** + Get the url of existing markdown content for a language. + */ + func existingContentUrl(for language: String) -> URL? { + let url = contentUrl(for: language) + guard url.exists else { + return nil + } + return url + } + + func hasContent(for language: String) -> Bool { + existingContentUrl(for: language) != nil + } + + +} diff --git a/WebsiteGenerator/Content/Section+LocalizedMetadata.swift b/WebsiteGenerator/Content/Section+LocalizedMetadata.swift new file mode 100644 index 0000000..26f6d59 --- /dev/null +++ b/WebsiteGenerator/Content/Section+LocalizedMetadata.swift @@ -0,0 +1,65 @@ +import Foundation + +extension Section { + + struct LocalizedMetadata { + + let id: String + + let title: String + + let subtitle: String? + + let description: String + + /** + The text on the link to show the section page when previewing multiple sections on an overview page. + */ + let moreLinkTitle: String + + /** + An optional text to display in the corner of the section thumbnail. + + Can be used to show things like "new", "draft", etc. + */ + let cornerText: String? + + let linkPreview: LinkPreviewMetadata? + + /** + The text on the back navigation link of contained elements. + + This text does not appear on the section page, but on the pages contained within the section. + */ + let backLinkText: String? + } + +} +extension Section.LocalizedMetadata: Codable { + +} + +extension Section.LocalizedMetadata: LanguageIdentifiable { + + var languageIdentifier: String { + id + } +} + +extension Section.LocalizedMetadata { + + static var initial: Section.LocalizedMetadata { + .init(id: "en", + title: "Section title", + subtitle: "Tag line below the title", + description: "The short text below the tagline on the section overview page", + moreLinkTitle: "More section items", + cornerText: nil, + linkPreview: .initial, + backLinkText: "Back to section") + } +} + +extension Section.LocalizedMetadata: LinkPreviewMetadataProvider { + +} diff --git a/WebsiteGenerator/Content/Section+Metadata.swift b/WebsiteGenerator/Content/Section+Metadata.swift new file mode 100644 index 0000000..74893d0 --- /dev/null +++ b/WebsiteGenerator/Content/Section+Metadata.swift @@ -0,0 +1,79 @@ +import Foundation + +extension Section { + + static let defaultSectionOverviewItemCount = 6 + + struct Metadata { + + let thumbnailStyle: ThumbnailStyle + + let sortByMostRecent: Bool + + let sortIndex: Int? + + let date: Date? + + let languages: [LocalizedMetadata] + + let sectionOverviewItemCount: Int + } +} + +extension Section.Metadata: Metadata { + + static let fileName = "section.json" + + static var initial: Section.Metadata { + .init(thumbnailStyle: .large, + sortByMostRecent: true, + sortIndex: nil, + date: nil, + languages: [.initial], + sectionOverviewItemCount: 6) + } +} + +extension Section.Metadata: LanguageContainer { + +} + +extension Section.Metadata: Codable { + + enum CodingKeys: CodingKey { + case thumbnailStyle + case sortByMostRecent + case sortIndex + case date + case languages + case sectionOverviewItemCount + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(thumbnailStyle, forKey: .thumbnailStyle) + try container.encode(sortByMostRecent, forKey: .sortByMostRecent) + try container.encodeIfPresent(sortIndex, forKey: .sortIndex) + try container.encode(languages, forKey: .languages) + if let date = date { + let dateString = Page.metadataDateFormatter.string(from: date) + try container.encode(dateString, forKey: .date) + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.thumbnailStyle = try container.decode(ThumbnailStyle.self, forKey: .thumbnailStyle) + self.sortByMostRecent = try container.decode(Bool.self, forKey: .sortByMostRecent) + self.sortIndex = try container.decodeIfPresent(Int.self, forKey: .sortIndex) + self.languages = try container.decode([Section.LocalizedMetadata].self, forKey: .languages) + if let dateString = try container.decodeIfPresent(String.self, forKey: .date) { + self.date = try Page.metadataDateFormatter.date(from: dateString) + .unwrap(or: .invalidDateInPageMetadata(dateString)) + } else { + self.date = nil + } + self.sectionOverviewItemCount = try container + .decodeIfPresent(Int.self, forKey: .sectionOverviewItemCount) ?? Section.defaultSectionOverviewItemCount + } +} diff --git a/WebsiteGenerator/Content/Section.swift b/WebsiteGenerator/Content/Section.swift new file mode 100644 index 0000000..f394b53 --- /dev/null +++ b/WebsiteGenerator/Content/Section.swift @@ -0,0 +1,74 @@ +import Foundation + +struct Section { + + let metadata: Metadata + + let inputFolder: URL + + let elements: [SiteElement] + + /// The path to get to the section from the root folder (no leading slash) + let path: String + + var folderName: String { + inputFolder.lastPathComponent + } + + var sortedItems: [SiteElement] { + guard metadata.sortByMostRecent else { + return elements.sorted { $0.sortIndex! < $1.sortIndex! } + } + return elements.sorted { $0.sortDate! > $1.sortDate! } + } + + init?(folder: URL, path: String) throws { + self.path = path + guard let metadata = try Metadata(in: folder) else { + return nil + } + self.metadata = metadata + self.inputFolder = folder + let elements: [SiteElement] = try FileSystem.folders(in: folder) + .compactMap { + let sectionPath = "\(path)/\($0.lastPathComponent)" + if Page.Metadata.exists(in: $0) { + return try Page(folder: $0, path: sectionPath) + } + if Section.Metadata.exists(in: $0) { + return try Section(folder: $0, path: sectionPath) + } + return nil + } + if metadata.sortByMostRecent { + self.elements = elements.sorted { $0.sortDate! > $1.sortDate! } + } else { + self.elements = elements.sorted { $0.sortIndex! < $1.sortIndex! } + } + #warning("Verify that all sort indices or sort dates are present") + print("Section \(folderName): \(elements.count) pages") + } +} + +extension Section: SiteElement { + + var sortIndex: Int? { + metadata.sortIndex + } + + var sortDate: Date? { + metadata.date + } + + func cornerText(for language: String) -> String? { + localized(for: language).cornerText + } + + func backLinkText(for language: String) -> String? { + localized(for: language).backLinkText + } +} + +extension Section: LocalizedMetadataContainer { + +} diff --git a/WebsiteGenerator/Content/Site+LocalizedMetadata.swift b/WebsiteGenerator/Content/Site+LocalizedMetadata.swift new file mode 100644 index 0000000..d266211 --- /dev/null +++ b/WebsiteGenerator/Content/Site+LocalizedMetadata.swift @@ -0,0 +1,71 @@ +import Foundation + +extension Site { + + struct LocalizedMetadata { + + let languageIdentifier: String + + let linkPreview: LinkPreviewMetadata? + + let title: String + + let subtitle: String? + + let description: String + + /** + The text on the back navigation link of contained elements. + + This text does not appear on the section page, but on the pages contained within the section. + */ + let backLinkText: String? + + /** + The back text to use for element which don't specify a `backLinkText` themselves. + */ + let defaultBackLinkText: String + + /** + The text to show as a title for placeholder boxes + + Placeholders are included in missing pages. + */ + let placeholderTitle: String + + /** + The text to show as a description for placeholder boxes + + Placeholders are included in missing pages. + */ + let placeholderText: String + } +} + +extension Site.LocalizedMetadata: Codable { + +} + +extension Site.LocalizedMetadata: LanguageIdentifiable { + +} + +extension Site.LocalizedMetadata { + + static var initial: Site.LocalizedMetadata { + .init( + languageIdentifier: "en", + linkPreview: .initial, + title: "Website name on front page", + subtitle: "Tag line on front page", + description: "Some text below the tag line on the title page", + backLinkText: "Back to start", + defaultBackLinkText: "Back", + placeholderTitle: "Content missing", + placeholderText: "This page is incomplete. Content will be added in the coming days.") + } +} + +extension Site.LocalizedMetadata: LinkPreviewMetadataProvider { + +} diff --git a/WebsiteGenerator/Content/Site+Metadata.swift b/WebsiteGenerator/Content/Site+Metadata.swift new file mode 100644 index 0000000..d53ec85 --- /dev/null +++ b/WebsiteGenerator/Content/Site+Metadata.swift @@ -0,0 +1,48 @@ +import Foundation + +extension Site { + + struct Metadata { + + let author: String + + let ignoredSubFolders: Set + + let topBarTitle: String? + + /** + The url where the site will be deployed. + + This value is required to build absolute links for link previews. + - Note: The path does not need to contain a trailing slash. + */ + let deployedBaseUrl: String + + let languages: [LocalizedMetadata] + + static func write(to url: URL) throws { + try Metadata.initial.writeJSON(to: url) + } + } +} + +extension Site.Metadata: LanguageContainer { + +} + +extension Site.Metadata: Codable { + +} + +extension Site.Metadata: Metadata { + + static let fileName = "site.json" + + static var initial: Self { + .init(author: "Author", + ignoredSubFolders: ["templates"], + topBarTitle: "Title", + deployedBaseUrl: "http://example.com", + languages: [.initial]) + } +} diff --git a/WebsiteGenerator/Content/Site.swift b/WebsiteGenerator/Content/Site.swift new file mode 100644 index 0000000..69b9669 --- /dev/null +++ b/WebsiteGenerator/Content/Site.swift @@ -0,0 +1,54 @@ +import Foundation + +struct Site { + + static let linkPreviewDesiredImageWidth = 1600 + + let elements: [SiteElement] + + let metadata: Metadata + + let inputFolder: URL + + init?(folder: URL) throws { + self.inputFolder = folder + + guard let metadata = try Metadata(in: folder) else { + return nil + } + guard !metadata.languages.isEmpty else { + throw GenerationError.invalidLanguageSpecification("No languages specified in site.json") + } + self.metadata = metadata + self.elements = try FileSystem.folders(in: folder) + .filter { !metadata.ignoredSubFolders.contains($0.lastPathComponent) } + .compactMap { sectionUrl in + return try Section( + folder: sectionUrl, path: sectionUrl.lastPathComponent) + } + print("Loaded site with \(elements.count) sections and \(metadata.languages.count) languages") + + // Create example metadata + _ = try? Page.Metadata(in: folder) + _ = try? Section.Metadata(in: folder) + } +} + +extension Site: LocalizedMetadataContainer { + +} + +extension Site: SiteElement { + + var sortIndex: Int? { 0 } + + var sortDate: Date? { nil } + + var path: String { "" } + + func cornerText(for language: String) -> String? { nil } + + func backLinkText(for language: String) throws -> String? { + localized(for: language).backLinkText + } +} diff --git a/WebsiteGenerator/Content/SiteElement.swift b/WebsiteGenerator/Content/SiteElement.swift new file mode 100644 index 0000000..9b291f8 --- /dev/null +++ b/WebsiteGenerator/Content/SiteElement.swift @@ -0,0 +1,199 @@ +import Foundation + +protocol SiteElement { + + /** + The sort index for the element when manual sorting is specified for the parent. + - Note: Elements are sorted in ascending order. + */ + var sortIndex: Int? { get } + + /** + The date used for sorting of the element, if automatic sorting is specified by the parent. + - Note: Elements are sorted by newest first. + */ + var sortDate: Date? { get } + + /** + The path to the element's folder in the source hierarchy (without a leading slash). + */ + var path: String { get } + + /** + The url of the element's folder in the source hierarchy. + - Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property. + */ + var inputFolder: URL { get } + + /** + The localized title of the element. + + This title is used as large text in overview pages, or as the `

` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews. + */ + func title(for language: String) -> String + + /** + The optional text to display in a thumbnail corner. + - Note: This text is only displayed for large thumbnails. + */ + func cornerText(for language: String) -> String? + + /** + The url to the element in the given language. + + If the `externalUrl` property is not set for the page metadata in the given language, then the standard path is returned. + - If this value starts with a slash, it is considered an absolute url to the same domain + - If the value starts with `http://` or `https://` it is considered an external url + - Otherwise the value is treated as a path from the root of the site. + */ + func fullPageUrl(for language: String) -> String + + /** + All elements contained within the element. + + If the element is a section, then this property contains the pages within. + */ + var elements: [SiteElement] { get } + + func backLinkText(for language: String) throws -> String? + +} + +extension SiteElement { + + func fullPageUrl(for language: String) -> String { + localizedPath(for: language) + } +} + +extension SiteElement { + + /** + The id of the section to which this element contains. + + This property is used to highlight the active section in the top bar. + + The section id is the folder name of the top-level section + */ + var sectionId: String { + path.components(separatedBy: "/").first! + } + + static var defaultThumbnailFileName: String { "thumbnail.jpg" } + + static func thumbnailFileNameLocalized(for language: String) -> String { + defaultThumbnailFileName.insert("-\(language)", beforeLast: ".") + } + + var containedFolder: String { + inputFolder.lastPathComponent + } + + var containsElements: Bool { + !elements.isEmpty + } + + var hasNestingElements: Bool { + elements.contains { $0.containsElements } + } + + /** + Get the full path of the thumbnail image for the language (relative to the root folder). + */ + func thumbnailFilePath(for language: String) -> String { + let specificImageName = Self.thumbnailFileNameLocalized(for: language) + let specificImageUrl = inputFolder.appendingPathComponent(specificImageName) + guard specificImageUrl.exists else { + return "\(path)/\(Self.defaultThumbnailFileName)" + } + return "\(path)/\(specificImageName)" + } + + /** + Gets the thumbnail image for the element. + + If a localized thumbnail exists, then this image name is returned. + */ + func thumbnailName(for language: String) -> String { + let specificImageName = "thumbnail-\(language).jpg" + let specificImageUrl = inputFolder.appendingPathComponent(specificImageName) + guard specificImageUrl.exists else { + return "\(inputFolder.lastPathComponent)/thumbnail.jpg" + } + return "\(inputFolder.lastPathComponent)/\(specificImageName)" + } + /** + Create an absolute path (relative to the root directory) for a file contained in the elements folder. + + This function is used to copy required input files and to generate images + */ + func pathRelativeToRootForContainedInputFile(_ filePath: String) -> String { + guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else { + return filePath + } + return "\(path)/\(filePath)" + } + + func backLinkText(for language: String) throws -> String? { nil } + + /** + Returns the full path (relative to the site root for a page of the element in the given language. + */ + func localizedPath(for language: String) -> String { + path != "" ? "\(path)/\(language).html" : "\(language).html" + } + + func relativePathToFileWithPath(_ filePath: String) -> String { + guard path != "" else { + return filePath + } + guard filePath.hasPrefix(path) else { + return filePath + } + return filePath.replacingOccurrences(of: path + "/", with: "") + } + + private var additionalHeadContentUrl: URL { + inputFolder.appendingPathComponent("head.html") + } + + var hasAdditionalHeadContent: Bool { + additionalHeadContentUrl.exists + } + + func customHeadContent() throws -> String? { + let url = additionalHeadContentUrl + guard url.exists else { + return nil + } + return try wrap(.failedToOpenFile(url.path)) { + try String(contentsOf: url) + } + } + + private var additionalFooterContentUrl: URL { + inputFolder.appendingPathComponent("footer.html") + } + + var hasAdditionalFooterContent: Bool { + additionalFooterContentUrl.exists + } + + func customFooterContent() throws -> String? { + let url = additionalFooterContentUrl + guard url.exists else { + return nil + } + return try wrap(.failedToOpenFile(url.path)) { + try String(contentsOf: url) + } + } +} + +extension SiteElement { + + func printContents() { + print(path) + elements.forEach { $0.printContents() } + } +} diff --git a/WebsiteGenerator/Extensions/Optional+Extensions.swift b/WebsiteGenerator/Extensions/Optional+Extensions.swift new file mode 100644 index 0000000..1f52cad --- /dev/null +++ b/WebsiteGenerator/Extensions/Optional+Extensions.swift @@ -0,0 +1,12 @@ +import Foundation +import Metal + +extension Optional { + + func unwrapped(_ closure: (Wrapped) -> T) -> T? { + if case let .some(value) = self { + return closure(value) + } + return nil + } +} diff --git a/WebsiteGenerator/Extensions/String+Extensions.swift b/WebsiteGenerator/Extensions/String+Extensions.swift new file mode 100644 index 0000000..f5fba0c --- /dev/null +++ b/WebsiteGenerator/Extensions/String+Extensions.swift @@ -0,0 +1,39 @@ +import Foundation + +extension String { + + var nonEmpty: String? { + self.isEmpty ? nil : self + } + + var trimmed: String { + trimmingCharacters(in: .whitespacesAndNewlines) + } + + func indented(by indentation: String) -> String { + components(separatedBy: "\n").joined(separator: "\n" + indentation) + } + + var withoutEmptyLines: String { + components(separatedBy: "\n") + .filter { !$0.trimmed.isEmpty } + .joined(separator: "\n") + } + + func dropAfterLast(_ separator: String) -> String { + components(separatedBy: separator).dropLast().joined(separator: separator) + } + + func dropBeforeFirst(_ separator: String) -> String { + components(separatedBy: separator).dropFirst().joined(separator: separator) + } + + func lastComponentAfter(_ separator: String) -> String { + components(separatedBy: separator).last! + } + + func insert(_ content: String, beforeLast separator: String) -> String { + let parts = components(separatedBy: separator) + return parts.dropLast().joined(separator: separator) + content + separator + parts.last! + } +} diff --git a/WebsiteGenerator/FileSystem.swift b/WebsiteGenerator/FileSystem.swift new file mode 100644 index 0000000..85a5dc8 --- /dev/null +++ b/WebsiteGenerator/FileSystem.swift @@ -0,0 +1,99 @@ +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 } + } + + 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) + } + + func delete() throws { + try FileSystem.fm.removeItem(at: self) + } + + func copy(to url: URL) throws { + try wrap(.failedToWriteFile(url.path)) { + if url.exists { + try url.delete() + } + try url.ensureParentFolderExistence() + try FileSystem.fm.copyItem(at: self, to: url) + } + } +} + +extension Data { + + func createFolderAndWrite(to url: URL) throws { + try url.ensureParentFolderExistence() +// if url.exists { +// print("Overwriting \(url.path)") +// } + try wrap(.failedToWriteFile(url.path)) { + try write(to: url) + } + } +} + +extension String { + + func createFolderAndWrite(to url: URL) throws { + try data(using: .utf8)!.createFolderAndWrite(to: url) + } +} + +extension Encodable { + + func writeJSON(to url: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + let content = try wrap(.failedToEncodeJSON(url.path)) { + try encoder.encode(self) + } + try content.createFolderAndWrite(to: url) + } +} + +extension Decodable { + + init(decodeFrom url: URL) throws { + let data = try wrap(.failedToOpenFile(url.path)) { + try Data(contentsOf: url) + } + self = try wrap({ .failedToDecodeJSON(file: url.path, error: $0.localizedDescription)}) { + try JSONDecoder().decode(Self.self, from: data) + } + } +} diff --git a/WebsiteGenerator/GenerationError.swift b/WebsiteGenerator/GenerationError.swift new file mode 100644 index 0000000..a3dbb55 --- /dev/null +++ b/WebsiteGenerator/GenerationError.swift @@ -0,0 +1,51 @@ +import Foundation + +enum GenerationError: Error { + case missingMarkerInTemplate(String) + case failedToLoadTemplate(String) + case failedToWriteFile(String) + case failedToOpenFile(String) + case failedToDecodeJSON(file: String, error: String) + case invalidLanguageSpecification(String) + case invalidDateInPageMetadata(String) + case failedToEncodeJSON(String) + case missingSectionMetadata(section: String, language: String) + case missingPageMetadata(page: String, language: String) + case missingPage(page: String, language: String) + case missingImage(String) + case conflictingImageSources(output: String, in1: String, in2: String) + case conflictingImageRatios(output: String, in1: String, in2: String) + case failedToGenerateImage(String) + case imageRatioMismatch(String) + +} + +func wrap(_ error: GenerationError, execute: () throws -> T) rethrows -> T { + do { + return try execute() + } catch let underlyingError { + print(underlyingError) + throw error + } +} + +func wrap(_ error: (Error) -> GenerationError, execute: () throws -> T) rethrows -> T { + do { + return try execute() + } catch let underlyingError { + print(underlyingError) + throw error(underlyingError) + } +} + +extension Optional { + + func unwrap(or error: GenerationError) throws -> Wrapped { + switch self { + case .none: + throw error + case .some(let wrapped): + return wrapped + } + } +} diff --git a/WebsiteGenerator/Generators/IndexPageGenerator.swift b/WebsiteGenerator/Generators/IndexPageGenerator.swift new file mode 100644 index 0000000..e7f9523 --- /dev/null +++ b/WebsiteGenerator/Generators/IndexPageGenerator.swift @@ -0,0 +1,60 @@ +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 + + init(factory: LocalizedSiteTemplate, imageProcessor: ImageProcessor) { + self.factory = factory + } + + func generate( + site: Site, + language: String, + languageButton: String?, + sectionItemCount: Int, + to url: URL) throws { + let localized = site.localized(for: language) + + var content = [OverviewPageTemplate.Key : String]() + content[.head] = try makeHead(site: site, language: language) + content[.topBar] = factory.topBar.generate(section: nil, languageButton: languageButton) + content[.title] = localized.title + content[.subtitle] = localized.subtitle + content[.titleText] = localized.description + let sections = site.elements.compactMap { $0 as? Section } + content[.sections] = try factory.overviewSection.generate( + sections: sections, + in: site, + language: language, + sectionItemCount: sectionItemCount) + content[.footer] = SiteGenerator.pageFooter + try factory.overviewPage.generate(content, to: url) + } + + 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())) + } +} diff --git a/WebsiteGenerator/Generators/MarkdownProcessor.swift b/WebsiteGenerator/Generators/MarkdownProcessor.swift new file mode 100644 index 0000000..dbda979 --- /dev/null +++ b/WebsiteGenerator/Generators/MarkdownProcessor.swift @@ -0,0 +1,130 @@ +import Foundation +import Ink + +struct PageContentGenerator { + + private let imageProcessor: ImageProcessor + + init(imageProcessor: ImageProcessor) { + self.imageProcessor = imageProcessor + } + + 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) + } + + var hasCodeContent = false + + let imageModifier = Modifier(target: .images) { html, markdown in + let result = processMarkdownImage(markdown: markdown, html: html, page: page) + switch result { + case .success(let content): + return content + case .failure(let error): + errorToThrow = error + return "" + } + } + let codeModifier = Modifier(target: .codeBlocks) { html, markdown in + if markdown.starts(with: "```swift") { + #warning("Syntax highlight swift code") + return html + } + hasCodeContent = true + return html + } + let parser = MarkdownParser(modifiers: [imageModifier, codeModifier]) + + #warning("Check links in markdown for (missing) files to copy") + if hasCodeContent { + #warning("Automatically add hljs hightlighting if code samples are found") + } + + let result = parser.html(from: content) + if let error = errorToThrow { + throw error + } + return result + } + + private func processMarkdownImage(markdown: Substring, html: String, page: Page) -> Result { + let fileAndTitle = markdown + .components(separatedBy: "(").last! + .components(separatedBy: ")").first! + + let file = fileAndTitle.components(separatedBy: " \"").first! // Remove title + let rightSubtitle: String? + if fileAndTitle.contains(" \"") { + rightSubtitle = fileAndTitle.dropBeforeFirst("\"").dropAfterLast("\"") + } else { + rightSubtitle = nil + } + let leftSubtitle = markdown + .components(separatedBy: "]").first! + .components(separatedBy: "[").last!.nonEmpty + + #warning("Specify page image width in configuration") + let pageImageWidth = 748 + let size: NSSize + let imagePath = page.pathRelativeToRootForContainedInputFile(file) + do { + size = try imageProcessor.requireImage( + source: imagePath, + destination: imagePath, + width: pageImageWidth, + desiredHeight: nil, + createDoubleVersion: true) + } catch { + return .failure(error) + } + let file2x = file.insert("@2x", beforeLast: ".") + #warning("Move HTML code to single location") + let result = articelImage( + image: file, + image2x: file2x, + width: size.width, + height: size.height, + rightSubtitle: rightSubtitle, + leftSubtitle: leftSubtitle) + return .success(result) + } + + private func articelImage(image: String, image2x: String, width: CGFloat, height: CGFloat, rightSubtitle: String?, leftSubtitle: String?) -> String { + let subtitleCode = subtitle(left: leftSubtitle, right: rightSubtitle) + return fullImageCode(image: image, image2x: image2x, width: width, height: height, subtitle: subtitleCode) + } + + private func articleImageWithoutSubtitle(image: String, image2x: String, width: CGFloat, height: CGFloat) -> String { + """ + + + + """ + } + + private func subtitle(left: String?, right: String?) -> String { + guard left != nil || right != nil else { + return "" + } + let leftCode = left.unwrapped { "\($0)" } ?? "" + let rightCode = right.unwrapped { "\($0)" } ?? "" + return """ +
+ \(leftCode) + \(rightCode) +
+ """ + } + + private func fullImageCode(image: String, image2x: String, width: CGFloat, height: CGFloat, subtitle: String) -> String { + """ + + + \(subtitle) + + """ + } +} diff --git a/WebsiteGenerator/Generators/OverviewPageGenerator.swift b/WebsiteGenerator/Generators/OverviewPageGenerator.swift new file mode 100644 index 0000000..06be9df --- /dev/null +++ b/WebsiteGenerator/Generators/OverviewPageGenerator.swift @@ -0,0 +1,62 @@ +import Foundation + +struct OverviewPageGenerator { + + private let factory: LocalizedSiteTemplate + + let outputFolder: URL + + init(factory: LocalizedSiteTemplate, imageProcessor: ImageProcessor) { + self.factory = factory + self.outputFolder = imageProcessor.outputFolder + } + + func generate( + section: Section, + language: String, + backText: String?) throws { + let url = outputFolder.appendingPathComponent(section.localizedPath(for: language)) + + let metadata = section.localized(for: language) + + var content = [OverviewPageTemplate.Key : String]() + content[.head] = try makeHead(section: section, language: language) + let languageButton = section.nextLanguage(for: language) + content[.topBar] = factory.topBar.generate( + section: section.sectionId, + languageButton: languageButton) + content[.sections] = try makeContent(section: section, language: language) + content[.title] = metadata.title + content[.subtitle] = metadata.subtitle + content[.titleText] = metadata.description + content[.footer] = SiteGenerator.pageFooter + content[.backLink] = backText.unwrapped { factory.makeBackLink(text: $0, language: language) } + try factory.overviewPage.generate(content, to: url) + } + + private func makeContent(section: Section, language: String) throws -> String { + if section.hasNestingElements { + let sections = section.elements.compactMap { $0 as? Section } + return try factory.overviewSection.generate( + sections: sections, + in: section, + language: language, + sectionItemCount: section.metadata.sectionOverviewItemCount) + } else { + return try 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) + } +} diff --git a/WebsiteGenerator/Generators/OverviewSectionGenerator.swift b/WebsiteGenerator/Generators/OverviewSectionGenerator.swift new file mode 100644 index 0000000..758d5f6 --- /dev/null +++ b/WebsiteGenerator/Generators/OverviewSectionGenerator.swift @@ -0,0 +1,69 @@ +import Foundation + +struct OverviewSectionGenerator { + + private let multipleSectionsTemplate: OverviewSectionTemplate + + private let singleSectionsTemplate: OverviewSectionCleanTemplate + + let imageProcessor: ImageProcessor + + private let generator: ThumbnailListGenerator + + init(factory: TemplateFactory, imageProcessor: ImageProcessor) { + self.multipleSectionsTemplate = factory.overviewSection + self.singleSectionsTemplate = factory.overviewSectionClean + self.imageProcessor = imageProcessor + self.generator = ThumbnailListGenerator(factory: factory, imageProcessor: imageProcessor) + } + + func generate(sections: [Section], in parent: SiteElement, language: String, sectionItemCount: Int) throws -> String { + try sections.map { section in + let metadata = section.localized(for: language) + let fullUrl = section.fullPageUrl(for: language) + let relativeUrl = parent.relativePathToFileWithPath(fullUrl) + + var content = [OverviewSectionTemplate.Key : String]() + content[.url] = relativeUrl + content[.title] = metadata.title + content[.items] = try sectionContent(section: section, in: parent, language: language, shownItemCount: sectionItemCount) + content[.more] = metadata.moreLinkTitle + + return multipleSectionsTemplate.generate(content) + } + .joined(separator: "\n") + } + + func generate(section: Section, language: String) throws -> String { + var content = [OverviewSectionCleanTemplate.Key : String]() + content[.items] = try 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] + 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) + } +} diff --git a/WebsiteGenerator/Generators/PageGenerator.swift b/WebsiteGenerator/Generators/PageGenerator.swift new file mode 100644 index 0000000..5358596 --- /dev/null +++ b/WebsiteGenerator/Generators/PageGenerator.swift @@ -0,0 +1,71 @@ +import Foundation +import Ink + +struct PageGenerator { + + struct NavigationLink { + + let link: String + + let text: String + } + + private let factory: LocalizedSiteTemplate + + private let imageProcessor: ImageProcessor + + init(factory: LocalizedSiteTemplate, imageProcessor: ImageProcessor) { + self.factory = factory + self.imageProcessor = imageProcessor + } + + func generate(page: Page, language: String, backText: String, nextPage: NavigationLink?, previousPage: NavigationLink?) throws { + guard !page.isExternalPage else { + return + } + guard !page.metadata.isDraft else { + return + } + let path = page.fullPageUrl(for: language) + let inputContentUrl = page.inputFolder.appendingPathComponent("\(language).md") + #warning("Make prev and next navigation relative") + let metadata = page.localized(for: language) + let nextLanguage = page.nextLanguage(for: language) + var content = [ContentPageTemplate.Key : String]() + content[.head] = try makeHead(page: page, language: language) + content[.topBar] = factory.topBar.generate(section: page.sectionId, languageButton: nextLanguage) + content[.backLink] = factory.makeBackLink(text: backText, language: language) + content[.title] = metadata.title + content[.subtitle] = metadata.subtitle + content[.date] = factory.makeDateString(start: page.metadata.date, end: page.metadata.endDate) + content[.content] = try makeContent(page: page, language: language, url: inputContentUrl) + 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() + + let url = imageProcessor.outputFolder.appendingPathComponent(path) + try factory.contentPage.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 + } + print("Generated page \(page.path)") + return try PageContentGenerator(imageProcessor: imageProcessor).generate(page: page, language: language, at: url) + } + + 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) + } +} diff --git a/WebsiteGenerator/Generators/PageHeadGenerator.swift b/WebsiteGenerator/Generators/PageHeadGenerator.swift new file mode 100644 index 0000000..613d315 --- /dev/null +++ b/WebsiteGenerator/Generators/PageHeadGenerator.swift @@ -0,0 +1,61 @@ +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 { + + let template: PageHeadTemplate + + let imageProcessor: ImageProcessor + + init(factory: TemplateFactory, imageProcessor: ImageProcessor) { + self.template = factory.pageHead + self.imageProcessor = imageProcessor + } + + func generate(page: PageHeadInfoProvider) throws -> String { + var content = [PageHeadTemplate.Key : String]() + content[.author] = page.author + content[.title] = page.linkPreviewTitle + content[.description] = page.linkPreviewDescription + if let image = page.linkPreviewImage { + // 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 imageProcessor.requireImage( + source: image, + destination: linkPreviewImagePath, + width: Site.linkPreviewDesiredImageWidth) + #warning("Make link preview image path absolute") + content[.image] = "" + } + content[.customPageContent] = page.customHeadContent + + return template.generate(content) + } +} diff --git a/WebsiteGenerator/Generators/SiteGenerator.swift b/WebsiteGenerator/Generators/SiteGenerator.swift new file mode 100644 index 0000000..f11e728 --- /dev/null +++ b/WebsiteGenerator/Generators/SiteGenerator.swift @@ -0,0 +1,80 @@ +import Foundation + +struct SiteGenerator { + + let site: Site + + let templates: TemplateFactory + + private let imageProcessor: ImageProcessor + + private var outputFolder: URL { + imageProcessor.outputFolder + } + + init(site: Site, imageProcessor: ImageProcessor) throws { + self.site = site + let templatesFolder = site.inputFolder.appendingPathComponent("templates") + self.templates = try TemplateFactory(templateFolder: templatesFolder) + self.imageProcessor = imageProcessor + } + + func generate() throws { + try site.metadata.languages.forEach { metadata in + let language = metadata.languageIdentifier + let template = try LocalizedSiteTemplate( + factory: templates, + language: language, + site: site, + imageProcessor: imageProcessor) + + + // Generate sections + let overviewGenerator = OverviewPageGenerator(factory: template, imageProcessor: imageProcessor) + let pageGenerator = PageGenerator(factory: template, imageProcessor: imageProcessor) + 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, + 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 { + #warning("Determine previous and next pages") + try pageGenerator.generate( + page: page, + language: language, + backText: backText ?? metadata.defaultBackLinkText, + nextPage: nil, + previousPage: nil) + } + } + + let generator = IndexPageGenerator( + factory: template, + imageProcessor: imageProcessor) + + // Generate front page + let relativeUrl = site.localizedPath(for: language) + let indexPageUrl = outputFolder.appendingPathComponent(relativeUrl) + let button = site.nextLanguage(for: language) + try generator.generate( + site: site, + language: language, + languageButton: button, + sectionItemCount: 6, + to: indexPageUrl) + } + } + + static let pageFooter: String = + """ + + + """ +} diff --git a/WebsiteGenerator/Generators/ThumbnailInfo.swift b/WebsiteGenerator/Generators/ThumbnailInfo.swift new file mode 100644 index 0000000..b62c7ee --- /dev/null +++ b/WebsiteGenerator/Generators/ThumbnailInfo.swift @@ -0,0 +1,14 @@ +import Foundation + +struct ThumbnailInfo { + + let url: String? + + let imageFilePath: String + + let imageHtmlUrl: String + + let title: String + + let cornerText: String? +} diff --git a/WebsiteGenerator/Generators/ThumbnailListGenerator.swift b/WebsiteGenerator/Generators/ThumbnailListGenerator.swift new file mode 100644 index 0000000..bb52615 --- /dev/null +++ b/WebsiteGenerator/Generators/ThumbnailListGenerator.swift @@ -0,0 +1,38 @@ +import Foundation + +struct ThumbnailListGenerator { + + private let factory: TemplateFactory + + let imageProcessor: ImageProcessor + + init(factory: TemplateFactory, imageProcessor: ImageProcessor) { + self.factory = factory + self.imageProcessor = imageProcessor + } + + func generateContent(items: [ThumbnailInfo], style: ThumbnailStyle) throws -> String { + try items.map { try itemContent($0, style: style) } + .joined(separator: "\n") + } + + private func itemContent(_ thumbnail: ThumbnailInfo, style: ThumbnailStyle) throws -> String { + 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 { + factory.largeThumbnail.makeCorner(text: $0) + } + + try imageProcessor.requireImage( + source: thumbnail.imageFilePath, + destination: thumbnail.imageFilePath, + width: style.width, + desiredHeight: style.height, + createDoubleVersion: true) + + return try factory.thumbnail(style: style).generate(content, shouldIndent: false) + } +} diff --git a/WebsiteGenerator/ImageProcessor.swift b/WebsiteGenerator/ImageProcessor.swift new file mode 100644 index 0000000..d8b16b5 --- /dev/null +++ b/WebsiteGenerator/ImageProcessor.swift @@ -0,0 +1,237 @@ +import Foundation +#if canImport(AppKit) +import AppKit +#endif + +final class ImageProcessor { + + 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 + + init(inputFolder: URL, outputFolder: URL) { + self.inputFolder = inputFolder + self.outputFolder = outputFolder + } + + private var tasks: [String : ImageOutput] = [:] + + @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 { + 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 + } + + // Just copy SVG files + guard destination.pathExtension.lowercased() != "svg" else { + 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 { + 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 { + guard let tiff = image.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else { + print("Failed to get jpg data for image \(url.path)") + throw GenerationError.failedToGenerateImage(url.path) + } + + guard let jpgData = tiffData.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(0.7)]) else { + print("Failed to get jpg data for image \(url.path)") + throw GenerationError.failedToGenerateImage(url.path) + } + try jpgData.createFolderAndWrite(to: url) + } +#endif +} + + +private extension Int { + + func multiply(by factor: Int) -> Int { + self * factor + } +} + +private func getScaledSize(of source: NSSize, to desiredWidth: CGFloat) -> NSSize { + if source.width == desiredWidth { + return source + } + + if source.width < desiredWidth { + // Keep existing image if image is too small already + return source + //print("Image \(destination.path) too small (wanted width \(desiredWidth), has only \(sourceWidth))") + } + + let height = source.height * desiredWidth / source.width + return NSSize(width: desiredWidth, height: height) +} + +private func scale(image: NSImage, to size: NSSize) -> NSImage { + guard image.size.width > size.width else { + return image + } + //resize image + return NSImage(size: size, flipped: false) { (resizedRect) -> Bool in + image.draw(in: resizedRect) + return true + } +} + +private extension NSSize { + + var ratio: CGFloat { + width / height + } +} diff --git a/WebsiteGenerator/Templates/Elements/BackNavigationTemplate.swift b/WebsiteGenerator/Templates/Elements/BackNavigationTemplate.swift new file mode 100644 index 0000000..cbf9643 --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/BackNavigationTemplate.swift @@ -0,0 +1,13 @@ +import Foundation + +struct BackNavigationTemplate: Template { + + enum Key: String, CaseIterable { + case url = "URL" + case text = "TEXT" + } + + static let templateName = "back.html" + + let raw: String +} diff --git a/WebsiteGenerator/Templates/Elements/OverviewSectionCleanTemplate.swift b/WebsiteGenerator/Templates/Elements/OverviewSectionCleanTemplate.swift new file mode 100644 index 0000000..0627dd3 --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/OverviewSectionCleanTemplate.swift @@ -0,0 +1,12 @@ +import Foundation + +struct OverviewSectionCleanTemplate: Template { + + enum Key: String, CaseIterable { + case items = "ITEMS" + } + + static let templateName = "overview-section-clean.html" + + let raw: String +} diff --git a/WebsiteGenerator/Templates/Elements/OverviewSectionTemplate.swift b/WebsiteGenerator/Templates/Elements/OverviewSectionTemplate.swift new file mode 100644 index 0000000..bf8fec3 --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/OverviewSectionTemplate.swift @@ -0,0 +1,15 @@ +import Foundation + +struct OverviewSectionTemplate: Template { + + enum Key: String, CaseIterable { + case url = "URL" + case title = "TITLE" + case items = "ITEMS" + case more = "MORE" + } + + static let templateName = "overview-section.html" + + let raw: String +} diff --git a/WebsiteGenerator/Templates/Elements/PageHeadTemplate.swift b/WebsiteGenerator/Templates/Elements/PageHeadTemplate.swift new file mode 100644 index 0000000..3206515 --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/PageHeadTemplate.swift @@ -0,0 +1,16 @@ +import Foundation + +struct PageHeadTemplate: Template { + + enum Key: String, CaseIterable { + case author = "AUTHOR" + case title = "TITLE" + case description = "DESCRIPTION" + case image = "IMAGE" + case customPageContent = "CUSTOM" + } + + let raw: String + + static let templateName = "head.html" +} diff --git a/WebsiteGenerator/Templates/Elements/PlaceholderTemplate.swift b/WebsiteGenerator/Templates/Elements/PlaceholderTemplate.swift new file mode 100644 index 0000000..1e1cd5d --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/PlaceholderTemplate.swift @@ -0,0 +1,13 @@ +import Foundation + +struct PlaceholderTemplate: Template { + + enum Key: String, CaseIterable { + case title = "TITLE" + case text = "TEXT" + } + + static let templateName = "empty.html" + + var raw: String +} diff --git a/WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift b/WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift new file mode 100644 index 0000000..6bb4691 --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift @@ -0,0 +1,50 @@ +import Foundation + +protocol ThumbnailTemplate { + + func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) throws -> String +} + +enum ThumbnailKey: String, CaseIterable { + case url = "URL" + case image = "IMAGE" + case image2x = "IMAGE_2X" + case title = "TITLE" + case corner = "CORNER" +} + +struct LargeThumbnailTemplate: Template, ThumbnailTemplate { + + typealias Key = ThumbnailKey + + static let templateName = "thumbnail-large.html" + + let raw: String + + func makeCorner(text: String) -> String { + "\(text)" + } + + func makeTitleSuffix(_ suffix: String) -> String { + "\(suffix)" + } +} + +struct SquareThumbnailTemplate: Template, ThumbnailTemplate { + + typealias Key = ThumbnailKey + + static let templateName = "thumbnail-square.html" + + let raw: String +} + +struct SmallThumbnailTemplate: Template, ThumbnailTemplate { + + typealias Key = ThumbnailKey + + static let templateName = "thumbnail-small.html" + + let raw: String +} + diff --git a/WebsiteGenerator/Templates/Elements/TopBarTemplate.swift b/WebsiteGenerator/Templates/Elements/TopBarTemplate.swift new file mode 100644 index 0000000..6c2234b --- /dev/null +++ b/WebsiteGenerator/Templates/Elements/TopBarTemplate.swift @@ -0,0 +1,15 @@ +import Foundation + +struct TopBarTemplate: Template { + + enum Key: String, CaseIterable { + case title = "TITLE" + case titleLink = "TITLE_URL" + case elements = "ELEMENTS" + case languageButton = "LANG_BUTTON" + } + + static let templateName = "bar.html" + + var raw: String +} diff --git a/WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift b/WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift new file mode 100644 index 0000000..e06eac1 --- /dev/null +++ b/WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift @@ -0,0 +1,167 @@ +import Foundation +import Ink + +struct LocalizedSiteTemplate { + + let author: String + + let factory: TemplateFactory + + let topBar: PrefilledTopBarTemplate + + // MARK: Site Elements + + var backNavigation: BackNavigationTemplate { + factory.backNavigation + } + + let pageHead: PageHeadGenerator + + let overviewSection: OverviewSectionGenerator + + let placeholder: String + + private let fullDateFormatter: DateFormatter + + private let month: DateFormatter + + private let day: DateFormatter + + var language: String { + 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 overviewPage: OverviewPageTemplate { + factory.overviewPage + } + + var contentPage: ContentPageTemplate { + factory.contentPage + } + + init(factory: TemplateFactory, language: String, site: Site, imageProcessor: ImageProcessor) throws { + self.author = site.metadata.author + self.factory = factory + + let df = DateFormatter() + df.dateStyle = .long + df.timeStyle = .none + df.locale = Locale(identifier: language) + self.fullDateFormatter = df + + let df2 = DateFormatter() + df2.dateFormat = "MMMM" + df2.locale = Locale(identifier: language) + self.month = df2 + + let df3 = DateFormatter() + df3.dateFormat = "dd" + df3.locale = Locale(identifier: language) + self.day = df3 + + 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) + self.pageHead = PageHeadGenerator( + factory: factory, + imageProcessor: imageProcessor) + self.overviewSection = OverviewSectionGenerator( + factory: factory, + imageProcessor: imageProcessor) + + self.placeholder = factory.placeholder.generate([ + .title: metadata.placeholderTitle, + .text: metadata.placeholderText]) + } + + // MARK: Content + + func makeBackLink(text: String, language: String) -> String { + let content: [BackNavigationTemplate.Key : String] = [ + .text: text, + .url: "../\(language).html" + ] + return backNavigation.generate(content) + } + + #warning("Move HTML code to single location") + + func makePrevText(_ text: String) -> String { + "\(text)" + } + + func makeNextText(_ text: String) -> String { + "\(text)" + } + + func makeDateString(start: Date, end: Date?) -> String { + guard let end = end else { + return fullDateFormatter.string(from: start) + } + + switch language { + case "de": + return makeGermanDateString(start: start, end: end) + case "en": + fallthrough + default: + return makeEnglishDateString(start: start, end: end) + } + } + + private func makeGermanDateString(start: Date, end: Date) -> String { + guard Calendar.current.isDate(start, equalTo: end, toGranularity: .year) else { + return "\(fullDateFormatter.string(from: start)) - \(fullDateFormatter.string(from: end))" + } + + let startDay = day.string(from: start) + guard Calendar.current.isDate(start, equalTo: end, toGranularity: .month) else { + let startMonth = month.string(from: start) + return "\(startDay). \(startMonth) - \(fullDateFormatter.string(from: end))" + } + return "\(startDay). - \(fullDateFormatter.string(from: end))" + } + + private func makeEnglishDateString(start: Date, end: Date) -> String { + guard Calendar.current.isDate(start, equalTo: end, toGranularity: .year) else { + return "\(fullDateFormatter.string(from: start)) - \(fullDateFormatter.string(from: end))" + } + + guard Calendar.current.isDate(start, equalTo: end, toGranularity: .month) else { + let startDay = day.string(from: start) + let startMonth = month.string(from: start) + return "\(startMonth) \(startDay) - \(fullDateFormatter.string(from: end))" + } + return fullDateFormatter.string(from: start) + .insert(" - \(day.string(from: end))", beforeLast: ",") + } +} + diff --git a/WebsiteGenerator/Templates/Filled/PrefilledTopBarTemplate.swift b/WebsiteGenerator/Templates/Filled/PrefilledTopBarTemplate.swift new file mode 100644 index 0000000..a7f6bb6 --- /dev/null +++ b/WebsiteGenerator/Templates/Filled/PrefilledTopBarTemplate.swift @@ -0,0 +1,58 @@ +import Foundation + +struct PrefilledTopBarTemplate { + + let language: String + + let sections: [SectionInfo] + + let topBarWebsiteTitle: String + + private let topBar: TopBarTemplate + + init(template: TopBarTemplate, language: String, sections: [SectionInfo], topBarWebsiteTitle: String) throws { + self.topBar = template + self.language = language + self.sections = sections + self.topBarWebsiteTitle = topBarWebsiteTitle + } + + func generate(section: String?, languageButton: String?) -> String { + var content = [TopBarTemplate.Key : String]() + content[.title] = topBarWebsiteTitle + content[.titleLink] = topBarWebsiteTitle(language: language) + content[.elements] = elements(activeSection: section) + content[.languageButton] = languageButton.unwrapped(topBarLanguageButton) ?? "" + return topBar.generate(content) + } + + private func elements(activeSection: String?) -> String { + sections + .map { + topBarNavigationLink(url: $0.url, text: $0.name, isActive: activeSection == $0.id) + } + .joined(separator: "\n") + } + + #warning("Move HTML code to single location") + private func topBarWebsiteTitle(language: String) -> String { + "/\(language).html" + } + + private func topBarLanguageButton(_ language: String) -> String { + "\(language)" + } + + private func topBarNavigationLink(url: String, text: String, isActive: Bool) -> String { + "\(text)" + } + + struct SectionInfo { + + let id: String + + let name: String + + let url: String + } +} diff --git a/WebsiteGenerator/Templates/Pages/ContentPageTemplate.swift b/WebsiteGenerator/Templates/Pages/ContentPageTemplate.swift new file mode 100644 index 0000000..3495e94 --- /dev/null +++ b/WebsiteGenerator/Templates/Pages/ContentPageTemplate.swift @@ -0,0 +1,23 @@ +import Foundation + +struct ContentPageTemplate: Template { + + enum Key: String, CaseIterable { + case head = "HEAD" + case topBar = "TOP_BAR" + case backLink = "BACK_LINK" + case title = "TITLE" + case subtitle = "SUBTITLE" + case date = "DATE" + case content = "CONTENT" + case previousPageLinkText = "PREV_TEXT" + case previousPageUrl = "PREV_LINK" + case nextPageLinkText = "NEXT_TEXT" + case nextPageUrl = "NEXT_LINK" + case footer = "FOOTER" + } + + static let templateName = "page.html" + + let raw: String +} diff --git a/WebsiteGenerator/Templates/Pages/OverviewPageTemplate.swift b/WebsiteGenerator/Templates/Pages/OverviewPageTemplate.swift new file mode 100644 index 0000000..3858741 --- /dev/null +++ b/WebsiteGenerator/Templates/Pages/OverviewPageTemplate.swift @@ -0,0 +1,19 @@ +import Foundation + +struct OverviewPageTemplate: Template { + + enum Key: String, CaseIterable { + case head = "HEAD" + case topBar = "TOP_BAR" + case backLink = "BACK_LINK" + case title = "TITLE" + case subtitle = "SUBTITLE" + case titleText = "TITLE_TEXT" + case sections = "SECTIONS" + case footer = "FOOTER" + } + + let raw: String + + static let templateName = "overview-page.html" +} diff --git a/WebsiteGenerator/Templates/Template.swift b/WebsiteGenerator/Templates/Template.swift new file mode 100644 index 0000000..e59e100 --- /dev/null +++ b/WebsiteGenerator/Templates/Template.swift @@ -0,0 +1,62 @@ +import Foundation + +protocol Template { + + associatedtype Key where Key: RawRepresentable, Key.RawValue == String, Key: CaseIterable, Key: Hashable + + static var templateName: String { get } + + var raw: String { get } + + init(raw: String) + +} + +extension Template { + + init(in folder: URL) throws { + let url = folder.appendingPathComponent(Self.templateName) + try self.init(from: url) + } + + init(from url: URL) throws { + let raw = try wrap(.failedToLoadTemplate(url.lastPathComponent)) { + try String(contentsOf: url) + } + self.init(raw: raw) + } + + func generate(_ content: [Key : String], to url: URL) throws { + let content = generate(content) + try wrap(.failedToWriteFile(url.path)) { + try content.createFolderAndWrite(to: url) + } + } + + func generate(_ content: [Key : String], shouldIndent: Bool = false) -> String { + var result = raw.components(separatedBy: "\n") + + Key.allCases.forEach { key in + let newContent = content[key]?.withoutEmptyLines ?? "" + let stringMarker = "" + let indices = result.enumerated().filter { $0.element.contains(stringMarker) } + .map { $0.offset } + guard !indices.isEmpty else { + return + } + for index in indices { + let old = result[index].components(separatedBy: stringMarker) + // Add indentation to all added lines + let indentation = old.first! + guard shouldIndent, indentation.trimmingCharacters(in: .whitespaces).isEmpty else { + // Prefix is not indentation, so just insert new content + result[index] = old.joined(separator: newContent) + continue + } + let indentedReplacements = newContent.indented(by: indentation) + result[index] = old.joined(separator: indentedReplacements) + } + } + return result.joined(separator: "\n").withoutEmptyLines + } +} diff --git a/WebsiteGenerator/Templates/TemplateFactory.swift b/WebsiteGenerator/Templates/TemplateFactory.swift new file mode 100644 index 0000000..8beef48 --- /dev/null +++ b/WebsiteGenerator/Templates/TemplateFactory.swift @@ -0,0 +1,62 @@ +import Foundation + +final class TemplateFactory { + + let templateFolder: URL + + // MARK: Site Elements + + let backNavigation: BackNavigationTemplate + + let pageHead: PageHeadTemplate + + let topBar: TopBarTemplate + + let overviewSection: OverviewSectionTemplate + + let overviewSectionClean: OverviewSectionCleanTemplate + + let placeholder: PlaceholderTemplate + + // MARK: Thumbnails + + let largeThumbnail: LargeThumbnailTemplate + + let squareThumbnail: SquareThumbnailTemplate + + let smallThumbnail: SmallThumbnailTemplate + + func thumbnail(style: ThumbnailStyle) -> ThumbnailTemplate { + switch style { + case .large: + return largeThumbnail + case .square: + return squareThumbnail + case .small: + return smallThumbnail + } + } + + // MARK: Pages + + let overviewPage: OverviewPageTemplate + + let contentPage: ContentPageTemplate + + // MARK: Init + + init(templateFolder: URL) throws { + self.templateFolder = templateFolder + self.backNavigation = try .init(in: templateFolder) + self.pageHead = try .init(in: templateFolder) + self.topBar = try .init(in: templateFolder) + self.overviewSection = try .init(in: templateFolder) + self.overviewSectionClean = try .init(in: templateFolder) + self.placeholder = try .init(in: templateFolder) + self.largeThumbnail = try .init(in: templateFolder) + self.squareThumbnail = try .init(in: templateFolder) + self.smallThumbnail = try .init(in: templateFolder) + self.overviewPage = try .init(in: templateFolder) + self.contentPage = try .init(in: templateFolder) + } +} diff --git a/WebsiteGenerator/ThumbnailStyle.swift b/WebsiteGenerator/ThumbnailStyle.swift new file mode 100644 index 0000000..f920460 --- /dev/null +++ b/WebsiteGenerator/ThumbnailStyle.swift @@ -0,0 +1,35 @@ +import Foundation + +enum ThumbnailStyle: String, CaseIterable { + + case large + + case square + + case small + + var height: Int { + switch self { + case .large: + return 210 + case .square: + return 178 + case .small: + return 78 + } + } + var width: Int { + switch self { + case .large: + return 374 + case .square: + return height + case .small: + return height + } + } +} + +extension ThumbnailStyle: Codable { + +} diff --git a/WebsiteGenerator/main.swift b/WebsiteGenerator/main.swift index 30e37ee..0011993 100644 --- a/WebsiteGenerator/main.swift +++ b/WebsiteGenerator/main.swift @@ -1,11 +1,20 @@ -// -// main.swift -// WebsiteGenerator -// -// Created by CH on 05.08.22. -// - import Foundation -print("Hello, World!") +let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace") +let outputDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace/Site") +let imageProcessor = ImageProcessor( + 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, imageProcessor: imageProcessor) +try siteGenerator.generate() + +print("Pages generated") +try imageProcessor.createImages() +print("Images generated") + +#warning("Check that all metadata for each language is present")