First version
This commit is contained in:
parent
104c5151b4
commit
14b935249f
@ -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 = "<group>"; };
|
||||
E22E8769289D84FD00E51191 /* Section+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+Metadata.swift"; sourceTree = "<group>"; };
|
||||
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailStyle.swift; sourceTree = "<group>"; };
|
||||
E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndexPageGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E8777289DA0E100E51191 /* GenerationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenerationError.swift; sourceTree = "<group>"; };
|
||||
E22E8779289DA9F900E51191 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; };
|
||||
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E877E289DC11F00E51191 /* Site+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Site+Metadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8781289DCCB600E51191 /* Section.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Section.swift; sourceTree = "<group>"; };
|
||||
E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Section+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8786289DDF4C00E51191 /* Page.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Page.swift; sourceTree = "<group>"; };
|
||||
E22E8788289DDF5700E51191 /* Page+Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+Metadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Page+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||
E22E8794289E81D700E51191 /* FileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSystem.swift; sourceTree = "<group>"; };
|
||||
E22E8797289EA42C00E51191 /* ImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessor.swift; sourceTree = "<group>"; };
|
||||
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Optional+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailListGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E87A3289F0C7000E51191 /* SiteGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E87A7289F0E7B00E51191 /* PageGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadGenerator.swift; sourceTree = "<group>"; };
|
||||
E22E87AB289F1D3700E51191 /* Template.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Template.swift; sourceTree = "<group>"; };
|
||||
E22E87AD289F1E0000E51191 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
|
||||
E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefilledTopBarTemplate.swift; sourceTree = "<group>"; };
|
||||
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailInfo.swift; sourceTree = "<group>"; };
|
||||
E22E87B5289FF67B00E51191 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadataProvider.swift; sourceTree = "<group>"; };
|
||||
E2C5A5D428A0223C00102A25 /* OverviewPageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewPageTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = "<group>"; };
|
||||
E2C5A5D828A023FA00102A25 /* PageHeadTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHeadTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopBarTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5DC28A036BE00102A25 /* OverviewSectionTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5E228A037F900102A25 /* ContentPageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentPageTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5E428A03A6500102A25 /* BackNavigationTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizedSiteTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5EB28A055E900102A25 /* SiteElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteElement.swift; sourceTree = "<group>"; };
|
||||
E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageContainer.swift; sourceTree = "<group>"; };
|
||||
E2D55EDA28A2511D00B9453E /* OverviewSectionCleanTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverviewSectionCleanTemplate.swift; sourceTree = "<group>"; };
|
||||
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadata.swift; sourceTree = "<group>"; };
|
||||
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = "<group>"; };
|
||||
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
E22E8799289EE02300E51191 /* Extensions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E22E879A289EE02F00E51191 /* Optional+Extensions.swift */,
|
||||
E22E87AD289F1E0000E51191 /* String+Extensions.swift */,
|
||||
);
|
||||
path = Extensions;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E22E87A1289F0BF000E51191 /* Content */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2C5A5EB28A055E900102A25 /* SiteElement.swift */,
|
||||
E2D55ED828A1BAD800B9453E /* LanguageContainer.swift */,
|
||||
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */,
|
||||
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */,
|
||||
E22E8786289DDF4C00E51191 /* Page.swift */,
|
||||
E22E8792289E7EC700E51191 /* Page+LocalizedMetadata.swift */,
|
||||
E22E8788289DDF5700E51191 /* Page+Metadata.swift */,
|
||||
E22E8781289DCCB600E51191 /* Section.swift */,
|
||||
E22E8783289DCD5E00E51191 /* Section+LocalizedMetadata.swift */,
|
||||
E22E8769289D84FD00E51191 /* Section+Metadata.swift */,
|
||||
E22E8779289DA9F900E51191 /* Site.swift */,
|
||||
E22E876D289D868100E51191 /* Site+LocalizedMetadata.swift */,
|
||||
E22E877E289DC11F00E51191 /* Site+Metadata.swift */,
|
||||
E22E87B5289FF67B00E51191 /* Metadata.swift */,
|
||||
);
|
||||
path = Content;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E22E87A2289F0C6200E51191 /* Generators */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E22E87A9289F1AEE00E51191 /* PageHeadGenerator.swift */,
|
||||
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */,
|
||||
E22E879F289F008200E51191 /* ThumbnailListGenerator.swift */,
|
||||
E22E877C289DBA0A00E51191 /* OverviewSectionGenerator.swift */,
|
||||
E22E87A3289F0C7000E51191 /* SiteGenerator.swift */,
|
||||
E22E8771289D8C2700E51191 /* IndexPageGenerator.swift */,
|
||||
E22E87A7289F0E7B00E51191 /* PageGenerator.swift */,
|
||||
E22E879D289EFDFC00E51191 /* OverviewPageGenerator.swift */,
|
||||
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */,
|
||||
);
|
||||
path = Generators;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2C5A5D328A0222B00102A25 /* Templates */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2C5A5EA28A047B100102A25 /* Filled */,
|
||||
E2C5A5E728A03E4000102A25 /* Pages */,
|
||||
E2C5A5E628A03B1600102A25 /* Elements */,
|
||||
E2C5A5D628A022C500102A25 /* TemplateFactory.swift */,
|
||||
E22E87AB289F1D3700E51191 /* Template.swift */,
|
||||
);
|
||||
path = Templates;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
E2C5A5E728A03E4000102A25 /* Pages */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E2C5A5D428A0223C00102A25 /* OverviewPageTemplate.swift */,
|
||||
E2C5A5E228A037F900102A25 /* ContentPageTemplate.swift */,
|
||||
);
|
||||
path = Pages;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E2C5A5EA28A047B100102A25 /* Filled */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */,
|
||||
E2C5A5E828A0451C00102A25 /* LocalizedSiteTemplate.swift */,
|
||||
);
|
||||
path = Filled;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* 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 */;
|
||||
}
|
||||
|
119
WebsiteGenerator/Content/LanguageContainer.swift
Normal file
119
WebsiteGenerator/Content/LanguageContainer.swift
Normal file
@ -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 `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
|
||||
*/
|
||||
func title(for language: String) -> String {
|
||||
localized(for: language).title
|
||||
}
|
||||
|
||||
|
||||
func nextLanguage(for languageIdentifier: String) -> String? {
|
||||
let langs = metadata.languages.map { $0.languageIdentifier }
|
||||
guard let index = langs.firstIndex(of: languageIdentifier) else {
|
||||
return nil
|
||||
}
|
||||
for i in 1..<langs.count {
|
||||
let next = langs[(index + i) % langs.count]
|
||||
guard hasContent(for: next) else {
|
||||
continue
|
||||
}
|
||||
guard next != languageIdentifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension LanguageContainer {
|
||||
|
||||
var languageIdentifiers: [String] {
|
||||
languages.map { $0.languageIdentifier }
|
||||
}
|
||||
|
||||
#warning("Throw better error for missing language")
|
||||
func localized(for language: String) -> LocalizedContainer {
|
||||
languages.first { $0.languageIdentifier == language }!
|
||||
}
|
||||
|
||||
/**
|
||||
The localized title of the element.
|
||||
|
||||
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
|
||||
*/
|
||||
func title(for language: String) -> String {
|
||||
localized(for: language).title
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalizedMetadataContainer where Self: SiteElement, Self.MetadataType.LocalizedContainer: LinkPreviewMetadataProvider {
|
||||
|
||||
private func linkPreviewImageFileName(for language: String) -> String? {
|
||||
if let fileName = localized(for: language).linkPreview?.image {
|
||||
return fileName
|
||||
}
|
||||
// Check for the existence of a localized thumbnail
|
||||
let fileName = Self.thumbnailFileNameLocalized(for: language)
|
||||
if inputFolder.appendingPathComponent(fileName).exists {
|
||||
return fileName
|
||||
}
|
||||
let defaultThumbnail = Self.defaultThumbnailFileName
|
||||
if inputFolder.appendingPathComponent(defaultThumbnail).exists {
|
||||
return defaultThumbnail
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func linkPreviewImage(for language: String) -> String? {
|
||||
guard let fileName = linkPreviewImageFileName(for: language) else {
|
||||
return nil
|
||||
}
|
||||
return "/\(path)/\(fileName)"
|
||||
}
|
||||
}
|
41
WebsiteGenerator/Content/LinkPreviewMetadata.swift
Normal file
41
WebsiteGenerator/Content/LinkPreviewMetadata.swift
Normal file
@ -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")
|
||||
}
|
||||
}
|
23
WebsiteGenerator/Content/LinkPreviewMetadataProvider.swift
Normal file
23
WebsiteGenerator/Content/LinkPreviewMetadataProvider.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
29
WebsiteGenerator/Content/Metadata.swift
Normal file
29
WebsiteGenerator/Content/Metadata.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
62
WebsiteGenerator/Content/Page+LocalizedMetadata.swift
Normal file
62
WebsiteGenerator/Content/Page+LocalizedMetadata.swift
Normal file
@ -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 }
|
||||
}
|
93
WebsiteGenerator/Content/Page+Metadata.swift
Normal file
93
WebsiteGenerator/Content/Page+Metadata.swift
Normal file
@ -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) ?? []
|
||||
}
|
||||
}
|
83
WebsiteGenerator/Content/Page.swift
Normal file
83
WebsiteGenerator/Content/Page.swift
Normal file
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
65
WebsiteGenerator/Content/Section+LocalizedMetadata.swift
Normal file
65
WebsiteGenerator/Content/Section+LocalizedMetadata.swift
Normal file
@ -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 {
|
||||
|
||||
}
|
79
WebsiteGenerator/Content/Section+Metadata.swift
Normal file
79
WebsiteGenerator/Content/Section+Metadata.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
74
WebsiteGenerator/Content/Section.swift
Normal file
74
WebsiteGenerator/Content/Section.swift
Normal file
@ -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 {
|
||||
|
||||
}
|
71
WebsiteGenerator/Content/Site+LocalizedMetadata.swift
Normal file
71
WebsiteGenerator/Content/Site+LocalizedMetadata.swift
Normal file
@ -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 {
|
||||
|
||||
}
|
48
WebsiteGenerator/Content/Site+Metadata.swift
Normal file
48
WebsiteGenerator/Content/Site+Metadata.swift
Normal file
@ -0,0 +1,48 @@
|
||||
import Foundation
|
||||
|
||||
extension Site {
|
||||
|
||||
struct Metadata {
|
||||
|
||||
let author: String
|
||||
|
||||
let ignoredSubFolders: Set<String>
|
||||
|
||||
let topBarTitle: String?
|
||||
|
||||
/**
|
||||
The url where the site will be deployed.
|
||||
|
||||
This value is required to build absolute links for link previews.
|
||||
- Note: The path does not need to contain a trailing slash.
|
||||
*/
|
||||
let deployedBaseUrl: String
|
||||
|
||||
let languages: [LocalizedMetadata]
|
||||
|
||||
static func write(to url: URL) throws {
|
||||
try Metadata.initial.writeJSON(to: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Site.Metadata: LanguageContainer {
|
||||
|
||||
}
|
||||
|
||||
extension Site.Metadata: Codable {
|
||||
|
||||
}
|
||||
|
||||
extension Site.Metadata: Metadata {
|
||||
|
||||
static let fileName = "site.json"
|
||||
|
||||
static var initial: Self {
|
||||
.init(author: "Author",
|
||||
ignoredSubFolders: ["templates"],
|
||||
topBarTitle: "<b>Title</b>",
|
||||
deployedBaseUrl: "http://example.com",
|
||||
languages: [.initial])
|
||||
}
|
||||
}
|
54
WebsiteGenerator/Content/Site.swift
Normal file
54
WebsiteGenerator/Content/Site.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
199
WebsiteGenerator/Content/SiteElement.swift
Normal file
199
WebsiteGenerator/Content/SiteElement.swift
Normal file
@ -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 `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
|
||||
*/
|
||||
func title(for language: String) -> String
|
||||
|
||||
/**
|
||||
The optional text to display in a thumbnail corner.
|
||||
- Note: This text is only displayed for large thumbnails.
|
||||
*/
|
||||
func cornerText(for language: String) -> String?
|
||||
|
||||
/**
|
||||
The url to the element in the given language.
|
||||
|
||||
If the `externalUrl` property is not set for the page metadata in the given language, then the standard path is returned.
|
||||
- If this value starts with a slash, it is considered an absolute url to the same domain
|
||||
- If the value starts with `http://` or `https://` it is considered an external url
|
||||
- Otherwise the value is treated as a path from the root of the site.
|
||||
*/
|
||||
func fullPageUrl(for language: String) -> String
|
||||
|
||||
/**
|
||||
All elements contained within the element.
|
||||
|
||||
If the element is a section, then this property contains the pages within.
|
||||
*/
|
||||
var elements: [SiteElement] { get }
|
||||
|
||||
func backLinkText(for language: String) throws -> String?
|
||||
|
||||
}
|
||||
|
||||
extension SiteElement {
|
||||
|
||||
func fullPageUrl(for language: String) -> String {
|
||||
localizedPath(for: language)
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteElement {
|
||||
|
||||
/**
|
||||
The id of the section to which this element contains.
|
||||
|
||||
This property is used to highlight the active section in the top bar.
|
||||
|
||||
The section id is the folder name of the top-level section
|
||||
*/
|
||||
var sectionId: String {
|
||||
path.components(separatedBy: "/").first!
|
||||
}
|
||||
|
||||
static var defaultThumbnailFileName: String { "thumbnail.jpg" }
|
||||
|
||||
static func thumbnailFileNameLocalized(for language: String) -> String {
|
||||
defaultThumbnailFileName.insert("-\(language)", beforeLast: ".")
|
||||
}
|
||||
|
||||
var containedFolder: String {
|
||||
inputFolder.lastPathComponent
|
||||
}
|
||||
|
||||
var containsElements: Bool {
|
||||
!elements.isEmpty
|
||||
}
|
||||
|
||||
var hasNestingElements: Bool {
|
||||
elements.contains { $0.containsElements }
|
||||
}
|
||||
|
||||
/**
|
||||
Get the full path of the thumbnail image for the language (relative to the root folder).
|
||||
*/
|
||||
func thumbnailFilePath(for language: String) -> String {
|
||||
let specificImageName = Self.thumbnailFileNameLocalized(for: language)
|
||||
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
|
||||
guard specificImageUrl.exists else {
|
||||
return "\(path)/\(Self.defaultThumbnailFileName)"
|
||||
}
|
||||
return "\(path)/\(specificImageName)"
|
||||
}
|
||||
|
||||
/**
|
||||
Gets the thumbnail image for the element.
|
||||
|
||||
If a localized thumbnail exists, then this image name is returned.
|
||||
*/
|
||||
func thumbnailName(for language: String) -> String {
|
||||
let specificImageName = "thumbnail-\(language).jpg"
|
||||
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
|
||||
guard specificImageUrl.exists else {
|
||||
return "\(inputFolder.lastPathComponent)/thumbnail.jpg"
|
||||
}
|
||||
return "\(inputFolder.lastPathComponent)/\(specificImageName)"
|
||||
}
|
||||
/**
|
||||
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
|
||||
|
||||
This function is used to copy required input files and to generate images
|
||||
*/
|
||||
func pathRelativeToRootForContainedInputFile(_ filePath: String) -> String {
|
||||
guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else {
|
||||
return filePath
|
||||
}
|
||||
return "\(path)/\(filePath)"
|
||||
}
|
||||
|
||||
func backLinkText(for language: String) throws -> String? { nil }
|
||||
|
||||
/**
|
||||
Returns the full path (relative to the site root for a page of the element in the given language.
|
||||
*/
|
||||
func localizedPath(for language: String) -> String {
|
||||
path != "" ? "\(path)/\(language).html" : "\(language).html"
|
||||
}
|
||||
|
||||
func relativePathToFileWithPath(_ filePath: String) -> String {
|
||||
guard path != "" else {
|
||||
return filePath
|
||||
}
|
||||
guard filePath.hasPrefix(path) else {
|
||||
return filePath
|
||||
}
|
||||
return filePath.replacingOccurrences(of: path + "/", with: "")
|
||||
}
|
||||
|
||||
private var additionalHeadContentUrl: URL {
|
||||
inputFolder.appendingPathComponent("head.html")
|
||||
}
|
||||
|
||||
var hasAdditionalHeadContent: Bool {
|
||||
additionalHeadContentUrl.exists
|
||||
}
|
||||
|
||||
func customHeadContent() throws -> String? {
|
||||
let url = additionalHeadContentUrl
|
||||
guard url.exists else {
|
||||
return nil
|
||||
}
|
||||
return try wrap(.failedToOpenFile(url.path)) {
|
||||
try String(contentsOf: url)
|
||||
}
|
||||
}
|
||||
|
||||
private var additionalFooterContentUrl: URL {
|
||||
inputFolder.appendingPathComponent("footer.html")
|
||||
}
|
||||
|
||||
var hasAdditionalFooterContent: Bool {
|
||||
additionalFooterContentUrl.exists
|
||||
}
|
||||
|
||||
func customFooterContent() throws -> String? {
|
||||
let url = additionalFooterContentUrl
|
||||
guard url.exists else {
|
||||
return nil
|
||||
}
|
||||
return try wrap(.failedToOpenFile(url.path)) {
|
||||
try String(contentsOf: url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteElement {
|
||||
|
||||
func printContents() {
|
||||
print(path)
|
||||
elements.forEach { $0.printContents() }
|
||||
}
|
||||
}
|
12
WebsiteGenerator/Extensions/Optional+Extensions.swift
Normal file
12
WebsiteGenerator/Extensions/Optional+Extensions.swift
Normal file
@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
import Metal
|
||||
|
||||
extension Optional {
|
||||
|
||||
func unwrapped<T>(_ closure: (Wrapped) -> T) -> T? {
|
||||
if case let .some(value) = self {
|
||||
return closure(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
39
WebsiteGenerator/Extensions/String+Extensions.swift
Normal file
39
WebsiteGenerator/Extensions/String+Extensions.swift
Normal file
@ -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!
|
||||
}
|
||||
}
|
99
WebsiteGenerator/FileSystem.swift
Normal file
99
WebsiteGenerator/FileSystem.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
51
WebsiteGenerator/GenerationError.swift
Normal file
51
WebsiteGenerator/GenerationError.swift
Normal file
@ -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<T>(_ error: GenerationError, execute: () throws -> T) rethrows -> T {
|
||||
do {
|
||||
return try execute()
|
||||
} catch let underlyingError {
|
||||
print(underlyingError)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func wrap<T>(_ error: (Error) -> GenerationError, execute: () throws -> T) rethrows -> T {
|
||||
do {
|
||||
return try execute()
|
||||
} catch let underlyingError {
|
||||
print(underlyingError)
|
||||
throw error(underlyingError)
|
||||
}
|
||||
}
|
||||
|
||||
extension Optional {
|
||||
|
||||
func unwrap(or error: GenerationError) throws -> Wrapped {
|
||||
switch self {
|
||||
case .none:
|
||||
throw error
|
||||
case .some(let wrapped):
|
||||
return wrapped
|
||||
}
|
||||
}
|
||||
}
|
60
WebsiteGenerator/Generators/IndexPageGenerator.swift
Normal file
60
WebsiteGenerator/Generators/IndexPageGenerator.swift
Normal file
@ -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()))
|
||||
}
|
||||
}
|
130
WebsiteGenerator/Generators/MarkdownProcessor.swift
Normal file
130
WebsiteGenerator/Generators/MarkdownProcessor.swift
Normal file
@ -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<String, Error> {
|
||||
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 {
|
||||
"""
|
||||
<span class="image">
|
||||
<img src="\(image)" srcset="\(image2x) 2x" width="\(Int(width))" height="\(Int(height))" loading="lazy"/>
|
||||
</span>
|
||||
"""
|
||||
}
|
||||
|
||||
private func subtitle(left: String?, right: String?) -> String {
|
||||
guard left != nil || right != nil else {
|
||||
return ""
|
||||
}
|
||||
let leftCode = left.unwrapped { "<span class=\"left\">\($0)</span>" } ?? ""
|
||||
let rightCode = right.unwrapped { "<span class=\"right\">\($0)</span>" } ?? ""
|
||||
return """
|
||||
<div class="subtitle">
|
||||
\(leftCode)
|
||||
\(rightCode)
|
||||
</div>
|
||||
"""
|
||||
}
|
||||
|
||||
private func fullImageCode(image: String, image2x: String, width: CGFloat, height: CGFloat, subtitle: String) -> String {
|
||||
"""
|
||||
<span class="image">
|
||||
<img src="\(image)" srcset="\(image2x) 2x" width="\(Int(width))" height="\(Int(height))" loading="lazy"/>
|
||||
\(subtitle)
|
||||
</span>
|
||||
"""
|
||||
}
|
||||
}
|
62
WebsiteGenerator/Generators/OverviewPageGenerator.swift
Normal file
62
WebsiteGenerator/Generators/OverviewPageGenerator.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
69
WebsiteGenerator/Generators/OverviewSectionGenerator.swift
Normal file
69
WebsiteGenerator/Generators/OverviewSectionGenerator.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
71
WebsiteGenerator/Generators/PageGenerator.swift
Normal file
71
WebsiteGenerator/Generators/PageGenerator.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
61
WebsiteGenerator/Generators/PageHeadGenerator.swift
Normal file
61
WebsiteGenerator/Generators/PageHeadGenerator.swift
Normal file
@ -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] = "<meta property=\"og:image\" content=\"\(linkPreviewImagePath)\" />"
|
||||
}
|
||||
content[.customPageContent] = page.customHeadContent
|
||||
|
||||
return template.generate(content)
|
||||
}
|
||||
}
|
80
WebsiteGenerator/Generators/SiteGenerator.swift
Normal file
80
WebsiteGenerator/Generators/SiteGenerator.swift
Normal file
@ -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 =
|
||||
"""
|
||||
<script src="/assets/js/jquery.js"></script>
|
||||
<script src="/assets/js/global.min.js"></script>
|
||||
"""
|
||||
}
|
14
WebsiteGenerator/Generators/ThumbnailInfo.swift
Normal file
14
WebsiteGenerator/Generators/ThumbnailInfo.swift
Normal file
@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
struct ThumbnailInfo {
|
||||
|
||||
let url: String?
|
||||
|
||||
let imageFilePath: String
|
||||
|
||||
let imageHtmlUrl: String
|
||||
|
||||
let title: String
|
||||
|
||||
let cornerText: String?
|
||||
}
|
38
WebsiteGenerator/Generators/ThumbnailListGenerator.swift
Normal file
38
WebsiteGenerator/Generators/ThumbnailListGenerator.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
237
WebsiteGenerator/ImageProcessor.swift
Normal file
237
WebsiteGenerator/ImageProcessor.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
@ -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
|
||||
}
|
16
WebsiteGenerator/Templates/Elements/PageHeadTemplate.swift
Normal file
16
WebsiteGenerator/Templates/Elements/PageHeadTemplate.swift
Normal file
@ -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"
|
||||
}
|
@ -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
|
||||
}
|
50
WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift
Normal file
50
WebsiteGenerator/Templates/Elements/ThumbnailTemplate.swift
Normal file
@ -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 {
|
||||
"<span class=\"corner\"><span>\(text)</span></span>"
|
||||
}
|
||||
|
||||
func makeTitleSuffix(_ suffix: String) -> String {
|
||||
"<span class=\"suffix\">\(suffix)</span>"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
15
WebsiteGenerator/Templates/Elements/TopBarTemplate.swift
Normal file
15
WebsiteGenerator/Templates/Elements/TopBarTemplate.swift
Normal file
@ -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
|
||||
}
|
167
WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift
Normal file
167
WebsiteGenerator/Templates/Filled/LocalizedSiteTemplate.swift
Normal file
@ -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 {
|
||||
"<span class=\"icon-back\"></span>\(text)"
|
||||
}
|
||||
|
||||
func makeNextText(_ text: String) -> String {
|
||||
"\(text)<span class=\"icon-next\"></span>"
|
||||
}
|
||||
|
||||
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: ",")
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
"<a href=\"\(language).html\">\(language)</a>"
|
||||
}
|
||||
|
||||
private func topBarNavigationLink(url: String, text: String, isActive: Bool) -> String {
|
||||
"<a\(isActive ? " class=\"active\"" : "") href=\"/\(url)\">\(text)</a>"
|
||||
}
|
||||
|
||||
struct SectionInfo {
|
||||
|
||||
let id: String
|
||||
|
||||
let name: String
|
||||
|
||||
let url: String
|
||||
}
|
||||
}
|
23
WebsiteGenerator/Templates/Pages/ContentPageTemplate.swift
Normal file
23
WebsiteGenerator/Templates/Pages/ContentPageTemplate.swift
Normal file
@ -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
|
||||
}
|
19
WebsiteGenerator/Templates/Pages/OverviewPageTemplate.swift
Normal file
19
WebsiteGenerator/Templates/Pages/OverviewPageTemplate.swift
Normal file
@ -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"
|
||||
}
|
62
WebsiteGenerator/Templates/Template.swift
Normal file
62
WebsiteGenerator/Templates/Template.swift
Normal file
@ -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 = "<!--\(key.rawValue)-->"
|
||||
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
|
||||
}
|
||||
}
|
62
WebsiteGenerator/Templates/TemplateFactory.swift
Normal file
62
WebsiteGenerator/Templates/TemplateFactory.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
35
WebsiteGenerator/ThumbnailStyle.swift
Normal file
35
WebsiteGenerator/ThumbnailStyle.swift
Normal file
@ -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 {
|
||||
|
||||
}
|
@ -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")
|
||||
|
Loading…
Reference in New Issue
Block a user