First version

This commit is contained in:
Christoph Hagen 2022-08-16 10:39:05 +02:00
parent 104c5151b4
commit 14b935249f
44 changed files with 2891 additions and 8 deletions

View File

@ -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 */;
}

View 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)"
}
}

View 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")
}
}

View 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
}
}

View 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)
}
}

View 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 }
}

View 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) ?? []
}
}

View 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
}
}

View 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 {
}

View 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
}
}

View 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 {
}

View 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 {
}

View 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])
}
}

View 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
}
}

View 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() }
}
}

View 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
}
}

View 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!
}
}

View 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)
}
}
}

View 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
}
}
}

View 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()))
}
}

View 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>
"""
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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)
}
}

View 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>
"""
}

View File

@ -0,0 +1,14 @@
import Foundation
struct ThumbnailInfo {
let url: String?
let imageFilePath: String
let imageHtmlUrl: String
let title: String
let cornerText: String?
}

View 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)
}
}

View 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
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View 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"
}

View File

@ -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
}

View 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
}

View 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
}

View 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: ",")
}
}

View File

@ -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
}
}

View 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
}

View 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"
}

View 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
}
}

View 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)
}
}

View 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 {
}

View File

@ -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")