From 06daa5e5fa45c9c2adfcf420f445e8f5e958f9d0 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Fri, 19 Aug 2022 18:05:06 +0200 Subject: [PATCH] Add first source scanning --- WebsiteGenerator.xcodeproj/project.pbxproj | 48 ++++ .../xcdebugger/Breakpoints_v2.xcbkptlist | 6 + .../Content/LinkPreviewMetadata.swift | 6 +- WebsiteGenerator/Content/Site.swift | 4 +- .../Extensions/Decodable+Extensions.swift | 8 + .../Extensions/Optional+Extensions.swift | 16 ++ WebsiteGenerator/FileSystem.swift | 3 + WebsiteGenerator/Generic/ContentError.swift | 27 ++ WebsiteGenerator/Generic/Context.swift | 14 ++ .../Generic/Element+LocalizedMetadata.swift | 223 ++++++++++++++++ WebsiteGenerator/Generic/Element.swift | 237 ++++++++++++++++++ WebsiteGenerator/Generic/ErrorOutput.swift | 137 ++++++++++ WebsiteGenerator/Generic/FileAccess.swift | 146 +++++++++++ .../Generic/GenericMetadata+Localized.swift | 203 +++++++++++++++ .../Generic/GenericMetadata.swift | 176 +++++++++++++ WebsiteGenerator/Generic/PageState.swift | 18 ++ WebsiteGenerator/main.swift | 20 ++ 17 files changed, 1287 insertions(+), 5 deletions(-) create mode 100644 WebsiteGenerator.xcodeproj/xcuserdata/ch.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist create mode 100644 WebsiteGenerator/Extensions/Decodable+Extensions.swift create mode 100644 WebsiteGenerator/Generic/ContentError.swift create mode 100644 WebsiteGenerator/Generic/Context.swift create mode 100644 WebsiteGenerator/Generic/Element+LocalizedMetadata.swift create mode 100644 WebsiteGenerator/Generic/Element.swift create mode 100644 WebsiteGenerator/Generic/ErrorOutput.swift create mode 100644 WebsiteGenerator/Generic/FileAccess.swift create mode 100644 WebsiteGenerator/Generic/GenericMetadata+Localized.swift create mode 100644 WebsiteGenerator/Generic/GenericMetadata.swift create mode 100644 WebsiteGenerator/Generic/PageState.swift diff --git a/WebsiteGenerator.xcodeproj/project.pbxproj b/WebsiteGenerator.xcodeproj/project.pbxproj index 2b6d879..057eeaa 100644 --- a/WebsiteGenerator.xcodeproj/project.pbxproj +++ b/WebsiteGenerator.xcodeproj/project.pbxproj @@ -35,6 +35,8 @@ E22E87B0289F221A00E51191 /* PrefilledTopBarTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */; }; E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B1289F296700E51191 /* ThumbnailInfo.swift */; }; E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E22E87B5289FF67B00E51191 /* Metadata.swift */; }; + E253C86928AFD86E0076B6D0 /* FileAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C86828AFD86E0076B6D0 /* FileAccess.swift */; }; + E253C86B28AFE0980076B6D0 /* Context.swift in Sources */ = {isa = PBXBuildFile; fileRef = E253C86A28AFE0980076B6D0 /* Context.swift */; }; E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */; }; E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */; }; E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2C5A5D628A022C500102A25 /* TemplateFactory.swift */; }; @@ -55,6 +57,14 @@ E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */; }; E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2728ACD84400632026 /* VideoType.swift */; }; E2F8FA2B28AD0BD200632026 /* Splash in Frameworks */ = {isa = PBXBuildFile; productRef = E2F8FA2A28AD0BD200632026 /* Splash */; }; + E2F8FA2D28AD2F5300632026 /* GenericMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */; }; + E2F8FA3028AD450B00632026 /* PageState.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2F28AD450B00632026 /* PageState.swift */; }; + E2F8FA3228AD456C00632026 /* GenericMetadata+Localized.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */; }; + E2F8FA3428AD6F3400632026 /* Element.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3328AD6F3400632026 /* Element.swift */; }; + E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */; }; + E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3728AE27A500632026 /* ContentError.swift */; }; + E2F8FA3A28AE313A00632026 /* ErrorOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3928AE313A00632026 /* ErrorOutput.swift */; }; + E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -98,6 +108,8 @@ E22E87AF289F221A00E51191 /* PrefilledTopBarTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrefilledTopBarTemplate.swift; sourceTree = ""; }; E22E87B1289F296700E51191 /* ThumbnailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailInfo.swift; sourceTree = ""; }; E22E87B5289FF67B00E51191 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = ""; }; + E253C86828AFD86E0076B6D0 /* FileAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccess.swift; sourceTree = ""; }; + E253C86A28AFE0980076B6D0 /* Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = ""; }; E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadataProvider.swift; sourceTree = ""; }; E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderTemplate.swift; sourceTree = ""; }; E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = ""; }; @@ -117,6 +129,14 @@ E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = ""; }; E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageVideoTemplate.swift; sourceTree = ""; }; E2F8FA2728ACD84400632026 /* VideoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoType.swift; sourceTree = ""; }; + E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMetadata.swift; sourceTree = ""; }; + E2F8FA2F28AD450B00632026 /* PageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageState.swift; sourceTree = ""; }; + E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GenericMetadata+Localized.swift"; sourceTree = ""; }; + E2F8FA3328AD6F3400632026 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = ""; }; + E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Element+LocalizedMetadata.swift"; sourceTree = ""; }; + E2F8FA3728AE27A500632026 /* ContentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentError.swift; sourceTree = ""; }; + E2F8FA3928AE313A00632026 /* ErrorOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorOutput.swift; sourceTree = ""; }; + E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decodable+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -151,6 +171,7 @@ E22E8761289D84C300E51191 /* WebsiteGenerator */ = { isa = PBXGroup; children = ( + E2F8FA2E28AD44FF00632026 /* Generic */, E22E8762289D84C300E51191 /* main.swift */, E22E87A1289F0BF000E51191 /* Content */, E22E87A2289F0C6200E51191 /* Generators */, @@ -170,6 +191,7 @@ children = ( E22E879A289EE02F00E51191 /* Optional+Extensions.swift */, E22E87AD289F1E0000E51191 /* String+Extensions.swift */, + E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -257,6 +279,22 @@ path = Filled; sourceTree = ""; }; + E2F8FA2E28AD44FF00632026 /* Generic */ = { + isa = PBXGroup; + children = ( + E253C86A28AFE0980076B6D0 /* Context.swift */, + E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */, + E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */, + E2F8FA2F28AD450B00632026 /* PageState.swift */, + E2F8FA3328AD6F3400632026 /* Element.swift */, + E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */, + E2F8FA3728AE27A500632026 /* ContentError.swift */, + E2F8FA3928AE313A00632026 /* ErrorOutput.swift */, + E253C86828AFD86E0076B6D0 /* FileAccess.swift */, + ); + path = Generic; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -325,9 +363,11 @@ files = ( E2C5A5D728A022C500102A25 /* TemplateFactory.swift in Sources */, E22E8772289D8C2700E51191 /* IndexPageGenerator.swift in Sources */, + E2F8FA3A28AE313A00632026 /* ErrorOutput.swift in Sources */, E22E876E289D868100E51191 /* Site+LocalizedMetadata.swift in Sources */, E2C5A5D528A0223C00102A25 /* HeaderTemplate.swift in Sources */, E22E876C289D855D00E51191 /* ThumbnailStyle.swift in Sources */, + E2F8FA2D28AD2F5300632026 /* GenericMetadata.swift in Sources */, E22E8798289EA42C00E51191 /* FileProcessor.swift in Sources */, E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */, E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */, @@ -336,6 +376,7 @@ E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */, E22E876A289D84FD00E51191 /* Section+Metadata.swift in Sources */, E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */, + E2F8FA3428AD6F3400632026 /* Element.swift in Sources */, E22E87AE289F1E0000E51191 /* String+Extensions.swift in Sources */, E22E879E289EFDFC00E51191 /* OverviewPageGenerator.swift in Sources */, E22E8793289E7EC700E51191 /* Page+LocalizedMetadata.swift in Sources */, @@ -346,6 +387,7 @@ E22E87A4289F0C7000E51191 /* SiteGenerator.swift in Sources */, E22E87AC289F1D3700E51191 /* Template.swift in Sources */, E22E87A0289F008200E51191 /* ThumbnailListGenerator.swift in Sources */, + E2F8FA3028AD450B00632026 /* PageState.swift in Sources */, E22E8784289DCD5E00E51191 /* Section+LocalizedMetadata.swift in Sources */, E22E8789289DDF5700E51191 /* Page+Metadata.swift in Sources */, E2C5A5EC28A055E900102A25 /* SiteElement.swift in Sources */, @@ -364,11 +406,17 @@ E22E8795289E81D700E51191 /* FileSystem.swift in Sources */, E2C5A5D928A023FA00102A25 /* PageHeadTemplate.swift in Sources */, E22E8763289D84C300E51191 /* main.swift in Sources */, + E253C86B28AFE0980076B6D0 /* Context.swift in Sources */, E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */, E22E877A289DA9F900E51191 /* Site.swift in Sources */, + E2F8FA3228AD456C00632026 /* GenericMetadata+Localized.swift in Sources */, E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */, + E2F8FA3C28AE685C00632026 /* Decodable+Extensions.swift in Sources */, E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */, E22E8787289DDF4C00E51191 /* Page.swift in Sources */, + E2F8FA3828AE27A500632026 /* ContentError.swift in Sources */, + E2F8FA3628AE233600632026 /* Element+LocalizedMetadata.swift in Sources */, + E253C86928AFD86E0076B6D0 /* FileAccess.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/WebsiteGenerator.xcodeproj/xcuserdata/ch.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/WebsiteGenerator.xcodeproj/xcuserdata/ch.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..39116a9 --- /dev/null +++ b/WebsiteGenerator.xcodeproj/xcuserdata/ch.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/WebsiteGenerator/Content/LinkPreviewMetadata.swift b/WebsiteGenerator/Content/LinkPreviewMetadata.swift index e80f6f1..1e32e7c 100644 --- a/WebsiteGenerator/Content/LinkPreviewMetadata.swift +++ b/WebsiteGenerator/Content/LinkPreviewMetadata.swift @@ -10,7 +10,7 @@ struct LinkPreviewMetadata { /** The title to use for the link preview. - If `nil` is specified, then the localized element title is used. + If `nil` is specified, then the localized element `title` is used. */ let title: String? @@ -23,8 +23,8 @@ struct LinkPreviewMetadata { /** 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. + - 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? } diff --git a/WebsiteGenerator/Content/Site.swift b/WebsiteGenerator/Content/Site.swift index 69b9669..73700ee 100644 --- a/WebsiteGenerator/Content/Site.swift +++ b/WebsiteGenerator/Content/Site.swift @@ -29,8 +29,8 @@ struct Site { 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) + //_ = try? Page.Metadata(in: folder) + //_ = try? Section.Metadata(in: folder) } } diff --git a/WebsiteGenerator/Extensions/Decodable+Extensions.swift b/WebsiteGenerator/Extensions/Decodable+Extensions.swift new file mode 100644 index 0000000..f7fd930 --- /dev/null +++ b/WebsiteGenerator/Extensions/Decodable+Extensions.swift @@ -0,0 +1,8 @@ +import Foundation + +extension JSONDecoder { + + func decode(from data: Data) throws -> T where T: Decodable { + try self.decode(T.self, from: data) + } +} diff --git a/WebsiteGenerator/Extensions/Optional+Extensions.swift b/WebsiteGenerator/Extensions/Optional+Extensions.swift index 1f52cad..c280067 100644 --- a/WebsiteGenerator/Extensions/Optional+Extensions.swift +++ b/WebsiteGenerator/Extensions/Optional+Extensions.swift @@ -9,4 +9,20 @@ extension Optional { } return nil } + + @discardableResult + func ifNil(_ closure: () -> Void) -> Self { + if self == nil { + closure() + } + return self + } + + @discardableResult + func ifNotNil(_ closure: () -> Void) -> Self { + if self != nil { + closure() + } + return self + } } diff --git a/WebsiteGenerator/FileSystem.swift b/WebsiteGenerator/FileSystem.swift index 80091ef..ee04d1a 100644 --- a/WebsiteGenerator/FileSystem.swift +++ b/WebsiteGenerator/FileSystem.swift @@ -42,6 +42,9 @@ extension URL { FileSystem.fm.fileExists(atPath: path) } + /** + Delete the file at the url. + */ func delete() throws { try FileSystem.fm.removeItem(at: self) } diff --git a/WebsiteGenerator/Generic/ContentError.swift b/WebsiteGenerator/Generic/ContentError.swift new file mode 100644 index 0000000..8eef8bf --- /dev/null +++ b/WebsiteGenerator/Generic/ContentError.swift @@ -0,0 +1,27 @@ +import Foundation + +struct ContentError: Error { + + let reason: String + + let source: String + + let error: Error? +} + +extension Optional { + + func unwrapped(or error: ContentError) throws -> Wrapped { + guard case let .some(value) = self else { + throw error + } + return value + } + + func unwrapOrFail(_ reason: String, source: String, error: Error? = nil) throws -> Wrapped { + guard case let .some(value) = self else { + throw ContentError(reason: reason, source: source, error: error) + } + return value + } +} diff --git a/WebsiteGenerator/Generic/Context.swift b/WebsiteGenerator/Generic/Context.swift new file mode 100644 index 0000000..5e732e4 --- /dev/null +++ b/WebsiteGenerator/Generic/Context.swift @@ -0,0 +1,14 @@ +import Foundation + +final class Context { + + let validation: ErrorOutput + + let fileSystem: FileAccess + + init(inputFolder: URL, outputFolder: URL) { + let validation = ErrorOutput() + self.validation = validation + self.fileSystem = FileAccess(in: inputFolder, errorOutput: validation) + } +} diff --git a/WebsiteGenerator/Generic/Element+LocalizedMetadata.swift b/WebsiteGenerator/Generic/Element+LocalizedMetadata.swift new file mode 100644 index 0000000..a1b9455 --- /dev/null +++ b/WebsiteGenerator/Generic/Element+LocalizedMetadata.swift @@ -0,0 +1,223 @@ +import Foundation + +extension Element { + + /** + Metadata localized for a specific language. + */ + struct LocalizedMetadata { + + static let moreLinkDefaultText = "DefaultMoreText" + + /** + The language for which the content is specified. + - Note: This field is mandatory + */ + let language: String + + /** + - Note: This field is mandatory + The title used in the page header. + */ + let title: String + + /** + The subtitle used in the page header. + */ + let subtitle: String? + + /** + The description text used in the page header. + */ + let description: String? + + /** + The title to use for the link preview. + + If `nil` is specified, then the localized element `title` is used. + */ + let linkPreviewTitle: 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 linkPreviewImage: 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 linkPreviewDescription: String + + /** + The text on the link to show the section page when previewing multiple sections on an overview page. + - Note: If this value is inherited from the parent, if it is not defined. There must be at least one + element in the path that defines this property. + */ + let moreLinkText: 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. + - Note: If this property is not specified, then the `defaultBackLinkText` is used + */ + let backLinkText: String + + /** + The text to show as a title for placeholder boxes + + Placeholders are included in missing pages. + - Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property. + */ + let placeholderTitle: String + + /** + The text to show as a description for placeholder boxes + + Placeholders are included in missing pages. + - Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property. + */ + let placeholderText: String + + /** + An optional suffix to add to the title on a page. + + This can be useful to express a different author, project grouping, etc. + */ + let titleSuffix: String? + + /** + An optional suffix to add to the thumbnail title of a page. + + This can be useful to express a different author, project grouping, etc. + */ + let thumbnailSuffix: String? + + /** + A text to place in the top right corner of a large thumbnail. + + The text should be a very short string to fit into the corner, like `soon`, or `draft` + + - Note: This property is ignored if `thumbnailStyle` is not `large`. + */ + 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 Element.LocalizedMetadata { + + init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata, with context: Context) { + let validation = context.validation + // Go through all elements and check them for completeness + // In the end, check that all required elements are present + var isComplete = true + func markAsIncomplete() { + isComplete = false + } + let source = "root" + self.language = validation + .required(data.language, name: "language", source: source) + .ifNil(markAsIncomplete) ?? "" + self.title = validation + .required(data.title, name: "title", source: source) + .ifNil(markAsIncomplete) ?? "" + self.subtitle = data.subtitle + self.description = data.description + self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? "" + self.linkPreviewImage = validation + .linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source) ?? "" + let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description + self.linkPreviewDescription = validation + .required(linkPreviewDescription, name: "linkPreviewDescription", source: source) + .ifNil(markAsIncomplete) ?? "" + self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText + self.backLinkText = validation + .required(data.backLinkText, name: "backLinkText", source: source) + .ifNil(markAsIncomplete) ?? "" + self.placeholderTitle = validation + .required(data.placeholderTitle, name: "placeholderTitle", source: source) + .ifNil(markAsIncomplete) ?? "" + self.placeholderText = validation + .required(data.placeholderText, name: "placeholderText", source: source) + .ifNil(markAsIncomplete) ?? "" + self.titleSuffix = data.titleSuffix + self.thumbnailSuffix = validation.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source) + self.cornerText = validation.unused(data.cornerText, "cornerText", source: source) + self.externalUrl = validation.unexpected(data.externalUrl, name: "externalUrl", source: source) + + guard isComplete else { + return nil + } + } + + init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata, with context: Context) { + let validation = context.validation + // Go through all elements and check them for completeness + // In the end, check that all required elements are present + var isComplete = true + func markAsIncomplete() { + isComplete = false + } + self.language = parent.language + self.title = validation + .required(data.title, name: "title", source: source) + .ifNil(markAsIncomplete) ?? "" + self.subtitle = data.subtitle + self.description = data.description + self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? "" + self.linkPreviewImage = validation + .linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source) ?? "" + let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description + self.linkPreviewDescription = validation + .required(linkPreviewDescription, name: "linkPreviewDescription", source: source) + .ifNil(markAsIncomplete) ?? "" + self.moreLinkText = validation.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source) + self.backLinkText = data.backLinkText ?? parent.backLinkText + self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle + self.placeholderText = data.placeholderText ?? parent.placeholderText + self.titleSuffix = data.titleSuffix + self.thumbnailSuffix = data.thumbnailSuffix + self.cornerText = data.cornerText + self.externalUrl = data.externalUrl + + guard isComplete else { + return nil + } + } +} + +// MARK: Thumbnails + +extension Element { + + static let defaultThumbnailName = "thumbnail.jpg" + + static func localizedThumbnailName(for language: String) -> String { + "thumbnail-\(language).jpg" + } + + static func findThumbnail(for language: String, in folder: URL) -> String? { + let localizedThumbnail = localizedThumbnailName(for: language) + let localizedThumbnailUrl = folder.appendingPathComponent(localizedThumbnail) + if localizedThumbnailUrl.exists { + return localizedThumbnail + } + let defaultThumbnailUrl = folder.appendingPathComponent(defaultThumbnailName) + if defaultThumbnailUrl.exists { + return defaultThumbnailName + } + return nil + } +} diff --git a/WebsiteGenerator/Generic/Element.swift b/WebsiteGenerator/Generic/Element.swift new file mode 100644 index 0000000..f7f16a8 --- /dev/null +++ b/WebsiteGenerator/Generic/Element.swift @@ -0,0 +1,237 @@ +import Foundation + +struct Element { + + static let overviewItemCountDefault = 6 + + /** + The author of the content. + + If no author is set, then the author from the parent element is used. + */ + let author: String + + /** + The title used in the top bar of the website, next to the logo. + + This title can be HTML content, and only the root level value is used. + */ + let topBarTitle: String + + /** + The url where the site will be deployed. + + This value is required to build absolute links for link previews. + + - Note: Only the root level value is used. + - Note: The path does not need to contain a trailing slash. + */ + let deployedBaseUrl: String + + /** + The (start) date of the element. + + The date is printed on content pages and may also used for sorting elements, + depending on the `useManualSorting` property of the parent. + */ + let date: Date? + + /** + The end date of the element. + + This property can be used to specify a date range for a content page. + */ + let endDate: Date? + + /** + The deployment state of the page. + + - Note: This property defaults to ``PageState.standard` + */ + let state: PageState + + /** + The sort index of the page for manual sorting. + + - Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set. + */ + let sortIndex: Int? + + /** + All files which may occur in content but is stored externally. + + Missing files which would otherwise produce a warning are ignored when included here. + - Note: This property defaults to an empty set. + */ + let externalFiles: Set + + /** + Specifies additional files which should be copied to the destination when generating the content. + - Note: This property defaults to an empty set. + */ + let requiredFiles: Set + + /** + The style of thumbnail to use when generating overviews. + + - Note: This property is only relevant for sections. + - Note: This property is inherited from the parent if not specified. + */ + let thumbnailStyle: ThumbnailStyle + + /** + Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`. + + - Note: This property is only relevant for sections. + - Note: This property defaults to `false` + */ + let useManualSorting: Bool + + /** + The number of items to show when generating overviews of this element. + - Note: This property is only relevant for sections. + - Note: This property is inherited from the parent if not specified. + */ + let overviewItemCount: Int + + /** + The localized metadata for each language. + */ + let languages: [LocalizedMetadata] + + /** + All elements contained within the element. + + If the element is a section, then this property contains the pages or subsections within. + */ + var elements: [Element] = [] + + /** + The url of the element's folder in the source hierarchy. + - Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property. + */ + let inputFolder: URL + + /** + The path to the element's folder in the source hierarchy (without a leading slash). + */ + let path: String + + /** + Create the root element of a site. + + The root element will recursively move into subfolders and build the site content + by looking for metadata files in each subfolder. + - Parameter folder: The root folder of the site content. + - Parameter context: The context to create the element (validation, file access, etc.) + */ + init?(atRoot folder: URL, with context: Context) throws { + let validation = context.validation + self.inputFolder = folder + self.path = "" + + let source = "root" + guard let metadata = try GenericMetadata(path: nil, with: context) else { + return nil + } + + self.author = validation.required(metadata.author, name: "author", source: source) ?? "author" + self.topBarTitle = validation + .required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website" + self.deployedBaseUrl = validation + .required(metadata.deployedBaseUrl, name: "deployedBaseUrl", source: source) ?? "https://example.com" + self.date = validation.unused(metadata.date, "date", source: source) + self.endDate = validation.unused(metadata.endDate, "endDate", source: source) + self.state = validation.state(metadata.state, source: source) + self.sortIndex = validation.unused(metadata.sortIndex, "sortIndex", source: source) + self.externalFiles = metadata.externalFiles ?? [] + self.requiredFiles = metadata.requiredFiles ?? [] + self.thumbnailStyle = validation.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large + self.useManualSorting = validation.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true + self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault + self.languages = validation.required(metadata.languages, name: "languages", source: source)? + .compactMap { language in + .init(atRoot: folder, data: language, with: context) + } ?? [] + try self.readElements(in: folder, source: nil, with: context) + } + + mutating func readElements(in folder: URL, source: String?, with context: Context) throws { + let subFolders: [URL] + do { + subFolders = try FileSystem.folders(in: folder) + } catch { + context.validation.add(error: "Failed to read subfolders", source: source ?? "root", error: error) + return + } + self.elements = try subFolders.compactMap { subFolder in + let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent + return try Element(parent: self, folder: subFolder, with: context, source: s) + } + } + + init?(parent: Element, folder: URL, with: Context, source: String) throws { + let validation = context.validation + self.inputFolder = folder + self.path = source + + guard let metadata = try GenericMetadata(path: source, with: context) else { + return nil + } + self.author = metadata.author ?? parent.author + self.topBarTitle = validation + .unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle + self.deployedBaseUrl = validation + .unused(metadata.deployedBaseUrl, "deployedBaseUrl", source: source) ?? parent.deployedBaseUrl + let date = validation.date(from: metadata.date, property: "date", source: source).ifNil { + if !parent.useManualSorting { + validation.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source) + } + } + self.date = date + self.endDate = validation.date(from: metadata.endDate, property: "endDate", source: source).ifNotNil { + if date == nil { + validation.add(warning: "Set 'endDate', but no 'date'", source: source) + } + } + self.state = validation.state(metadata.state, source: source) + self.sortIndex = metadata.sortIndex.ifNil { + if parent.useManualSorting { + validation.add(error: "No 'sortIndex', but parent defines 'useManualSorting' = true", source: source) + } + } + // TODO: Propagate external files from the parent if subpath matches? + self.externalFiles = metadata.externalFiles ?? [] + self.requiredFiles = metadata.requiredFiles ?? [] + self.thumbnailStyle = validation.thumbnailStyle(metadata.state, source: source) + self.useManualSorting = metadata.useManualSorting ?? false + self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount + self.languages = parent.languages.compactMap { parentData in + guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else { + validation.add(info: "Language '\(parentData.language)' not found", source: source) + return nil + } + return .init(folder: folder, data: data, source: source, parent: parentData, with: context) + } + // Check that each 'language' tag is present, and that all languages appear in the parent + validation.required(metadata.languages, name: "languages", source: source)? + .compactMap { validation.required($0.language, name: "language", source: source) } + .filter { language in + !parent.languages.contains { $0.language == language } + } + .forEach { + validation.add(warning: "Language '\($0)' not found in parent, so not generated", source: source) + } + try self.readElements(in: folder, source: source, with: context) + } +} + +// MARK: Debug + +extension Element { + + func printTree(indentation: String = "") { + print(indentation + "/" + path) + elements.forEach { $0.printTree(indentation: indentation + " ") } + } +} diff --git a/WebsiteGenerator/Generic/ErrorOutput.swift b/WebsiteGenerator/Generic/ErrorOutput.swift new file mode 100644 index 0000000..0ddc04f --- /dev/null +++ b/WebsiteGenerator/Generic/ErrorOutput.swift @@ -0,0 +1,137 @@ +import Foundation + +final class ErrorOutput { + + init() { + + } + + func add(error: ContentError) { + print("[ERROR] Reason: \(error.reason), Source: \(error.source), Error: \(error.error?.localizedDescription ?? "nil")") + } + + func add(error reason: String, source: String, error: Error? = nil) { + add(error: .init(reason: reason, source: source, error: error)) + } + + func add(warning: ContentError) { + print("[WARNING] Reason: \(warning.reason), Source: \(warning.source), Error: \(warning.error?.localizedDescription ?? "nil")") + } + + func add(warning reason: String, source: String, error: Error? = nil) { + add(warning: .init(reason: reason, source: source, error: error)) + } + + func add(info: ContentError) { + print("[INFO] Reason: \(info.reason), Source: \(info.source), Error: \(info.error?.localizedDescription ?? "nil")") + } + + func add(info reason: String, source: String, error: Error? = nil) { + add(info: .init(reason: reason, source: source, error: error)) + } + + @discardableResult + func unused(_ value: Optional, _ name: String, source: String) -> Optional { + if value != nil { + add(info: "Unused property '\(name)'", source: source) + } + return nil + } + + func unknown(property: String, source: String) { + add(info: "Unknown property '\(property)'", source: source) + } + + func required(_ value: Optional, name: String, source: String) -> Optional { + guard let value = value else { + add(error: "Missing property '\(name)'", source: source) + return nil + } + return value + } + + func unexpected(_ value: Optional, name: String, source: String) -> Optional { + if let value = value { + add(error: "Unexpected property '\(name)' = '\(value)'", source: source) + return nil + } + return nil + } + + func missing(_ file: String, requiredBy source: String) { + print("[ERROR] Missing file '\(file)' required by \(source)") + } + + func failedToOpen(_ file: String, requiredBy source: String, error: Error?) { + print("[ERROR] Failed to open file '\(file)' required by \(source): \(error?.localizedDescription ?? "No error provided")") + } + + func state(_ raw: String?, source: String) -> PageState { + guard let raw = raw else { + return .standard + } + guard let state = PageState(rawValue: raw) else { + add(warning: "Invalid 'state' '\(raw)', using 'standard'", source: source) + return .standard + } + return state + } + + func thumbnailStyle(_ raw: String?, source: String) -> ThumbnailStyle { + guard let raw = raw else { + return .large + } + guard let style = ThumbnailStyle(rawValue: raw) else { + add(warning: "Invalid 'thumbnailStyle' '\(raw)', using 'large'", source: source) + return .large + } + return style + } + + func linkPreviewThumbnail(customFile: String?, for language: String, in folder: URL, source: String) -> String? { + if let customFile = customFile { + #warning("Allow absolute urls for link preview thumbnails") + let customFileUrl = folder.appendingPathComponent(customFile) + guard customFileUrl.exists else { + missing(customFile, requiredBy: "property 'linkPreviewImage' in metadata of \(source)") + return nil + } + return customFile + } + guard let thumbnail = Element.findThumbnail(for: language, in: folder) else { + // Link preview images are not necessarily required + return nil + } + return thumbnail + } + + func moreLinkText(_ elementText: String?, parent parentText: String?, source: String) -> String { + if let elementText = elementText { + return elementText + } + let standard = Element.LocalizedMetadata.moreLinkDefaultText + guard let parentText = parentText, parentText != standard else { + add(error: "Missing property 'moreLinkText'", source: source) + return standard + } + + return parentText + } + + func date(from string: String?, property: String, source: String) -> Date? { + guard let string = string else { + return nil + } + guard let date = ErrorOutput.metadataDate.date(from: string) else { + add(warning: "Invalid date string '\(string)' for property '\(property)'", source: source) + return nil + } + return date + } + + private static let metadataDate: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "dd.MM.yy" + return df + }() +} diff --git a/WebsiteGenerator/Generic/FileAccess.swift b/WebsiteGenerator/Generic/FileAccess.swift new file mode 100644 index 0000000..a10dc7a --- /dev/null +++ b/WebsiteGenerator/Generic/FileAccess.swift @@ -0,0 +1,146 @@ +import Foundation + +enum FileAccessError: Error { + case failedToReadFile(String, Error) +} + +final class FileAccess { + + static let accessTimesFileName = "access.json" + + let errorOutput: ErrorOutput + + let sourceFolder: URL + + private let source = "FileAccess" + + private var modificationTimeCacheFile: URL { + sourceFolder.appendingPathComponent(FileAccess.accessTimesFileName) + } + + /** + The time stamps of last modified times for all accessed source files. + + The key is the relative path to the file from the source + */ + private var sourceLastModifiedTimes: [String : Date] = [:] + + private var changedFiles: Set = [] + + init(in root: URL, errorOutput: ErrorOutput) { + self.sourceFolder = root + self.errorOutput = errorOutput + + loadSavedModificationTimes() + } + + private func loadSavedModificationTimes() { + let url = modificationTimeCacheFile + guard url.exists else { + errorOutput.add(info: "No file modification times loaded, regarding all content as new", source: source) + return + } + let data: Data + do { + data = try Data(contentsOf: url) + } catch { + errorOutput.add( + warning: "File modification times data not read, regarding all content as new", + source: source, + error: error) + return + } + do { + self.sourceLastModifiedTimes = try JSONDecoder().decode(from: data) + } catch { + errorOutput.add( + warning: "File modification times not decoded, regarding all content as new", + source: source, + error: error) + try? url.delete() + return + } + } + + private func didAccess(inputPath: String, modified lastModified: Date, source: String) { + guard let previousDate = sourceLastModifiedTimes[inputPath] else { + // File not processed before, so mark as changed + changedFiles.insert(inputPath) + return + } + guard lastModified > previousDate else { + // File is unchanged + return + } + changedFiles.insert(inputPath) + } + + private func lastModifiedTime(of url: URL) -> Date? { + guard url.exists else { + return nil + } + do { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let date = attributes[.modificationDate] as? Date else { + errorOutput.add(warning: "Failed to read modification time of \(url.path)", source: source) + return nil + } + return date + } catch { + errorOutput.add(warning: "Failed to read file attributes of \(url.path)", source: source) + return nil + } + } + + func loadStringContent(inputPath: String) throws -> String? { + try load(inputPath: inputPath, String.init) + } + + func loadDataContent(inputPath: String) throws -> Data? { + try load(inputPath: inputPath) { try Data(contentsOf: $0) } + } + + private func load(inputPath: String, _ closure: (URL) throws -> T) rethrows -> T? { + let url = sourceFolder.appendingPathComponent(inputPath) + guard let modifiedDate = lastModifiedTime(of: url) else { + sourceLastModifiedTimes[inputPath] = nil + return nil + } + do { + let content = try closure(url) + didAccess(inputPath: inputPath, modified: modifiedDate, source: source) + return content + } catch { + throw FileAccessError.failedToReadFile(inputPath, error) + } + } + + func didGenerateAllFiles() { + for file in changedFiles { + let url = sourceFolder.appendingPathComponent(file) + guard let date = lastModifiedTime(of: url) else { + continue + } + sourceLastModifiedTimes[file] = date + } + do { + let data = try JSONEncoder().encode(sourceLastModifiedTimes) + try data.write(to: modificationTimeCacheFile) + } catch { + errorOutput.add(warning: "Failed to save modification times", source: source, error: error) + } + } + + func printChangedFilesOverview() { + let count = changedFiles.count + guard count > 0 else { + print("No files modified") + return + } + print("\(count) files modified:") + changedFiles.prefix(10).forEach { print(" " + $0) } + if count > 10 { + print(" ...") + } + } +} diff --git a/WebsiteGenerator/Generic/GenericMetadata+Localized.swift b/WebsiteGenerator/Generic/GenericMetadata+Localized.swift new file mode 100644 index 0000000..de3f14b --- /dev/null +++ b/WebsiteGenerator/Generic/GenericMetadata+Localized.swift @@ -0,0 +1,203 @@ +import Foundation + +extension GenericMetadata { + + /** + Metadata localized for a specific language. + */ + struct LocalizedMetadata { + + /** + The language for which the content is specified. + - Note: This field is mandatory + */ + let language: String? + + /** + - Note: This field is mandatory + The title used in the page header. + */ + let title: String? + + /** + The subtitle used in the page header. + */ + let subtitle: String? + + /** + The description text used in the page header + */ + let description: String? + + /** + The title to use for the link preview. + + If `nil` is specified, then the localized element `title` is used. + */ + let linkPreviewTitle: 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 linkPreviewImage: 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 linkPreviewDescription: String? + + /** + The text on the link to show the section page when previewing multiple sections on an overview page. + - Note: If this value is inherited from the parent, if it is not defined. There must be at least one + element in the path that defines this property. + */ + let moreLinkText: 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. + - Note: If this property is not specified, then the root `backLinkText` is used. + - Note: The root element must specify this property. + */ + let backLinkText: String? + + /** + The text to show as a title for placeholder boxes + + Placeholders are included in missing pages. + - Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property. + */ + let placeholderTitle: String? + + /** + The text to show as a description for placeholder boxes + + Placeholders are included in missing pages. + - Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property. + */ + let placeholderText: String? + + /** + An optional suffix to add to the title on a page. + + This can be useful to express a different author, project grouping, etc. + */ + let titleSuffix: String? + + /** + An optional suffix to add to the thumbnail title of a page. + + This can be useful to express a different author, project grouping, etc. + */ + let thumbnailSuffix: String? + + /** + A text to place in the top right corner of a large thumbnail. + + The text should be a very short string to fit into the corner, like `soon`, or `draft` + + - Note: This property is ignored if `thumbnailStyle` is not `large`. + */ + 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 GenericMetadata.LocalizedMetadata: Codable { + + private static var knownKeyList: [CodingKeys] { + [ + .language, + .title, + .subtitle, + .description, + .linkPreviewTitle, + .linkPreviewImage, + .linkPreviewDescription, + .moreLinkText, + .backLinkText, + .placeholderTitle, + .placeholderText + ] + } + + static var knownKeys: Set { + Set(knownKeyList.map { $0.stringValue }) + } +} + +extension GenericMetadata.LocalizedMetadata { + + /** + The mandatory minimum for a site element. + */ + static var mandatory: GenericMetadata.LocalizedMetadata { + .init( + language: "", + title: "", + subtitle: nil, + description: nil, + linkPreviewTitle: nil, + linkPreviewImage: nil, + linkPreviewDescription: nil, + moreLinkText: nil, + backLinkText: nil, + placeholderTitle: nil, + placeholderText: nil, + titleSuffix: nil, + thumbnailSuffix: nil, + cornerText: nil, + externalUrl: nil) + } + + /** + The mandatory minimum for the root element of a site. + */ + static var mandatoryAtRoot: GenericMetadata.LocalizedMetadata { + .init(language: "", + title: "", + subtitle: nil, + description: nil, + linkPreviewTitle: nil, + linkPreviewImage: nil, + linkPreviewDescription: nil, + moreLinkText: nil, + backLinkText: "", + placeholderTitle: "", + placeholderText: "", + titleSuffix: nil, + thumbnailSuffix: nil, + cornerText: nil, + externalUrl: nil) + } + + static var full: GenericMetadata.LocalizedMetadata { + .init(language: "", + title: "", + subtitle: "", + description: "", + linkPreviewTitle: "", + linkPreviewImage: "", + linkPreviewDescription: "", + moreLinkText: "", + backLinkText: "", + placeholderTitle: "", + placeholderText: "", + titleSuffix: "", + thumbnailSuffix: "", + cornerText: "", + externalUrl: "") + } +} diff --git a/WebsiteGenerator/Generic/GenericMetadata.swift b/WebsiteGenerator/Generic/GenericMetadata.swift new file mode 100644 index 0000000..914b812 --- /dev/null +++ b/WebsiteGenerator/Generic/GenericMetadata.swift @@ -0,0 +1,176 @@ +import Foundation + +/** + The metadata for all site elements. + */ +struct GenericMetadata { + + /** + The name of the metadata file contained in the folder of each site element. + */ + static let metadataFileName = "metadata.json" + + /** + The author of the content. + + If no author is set, then the author from the parent element is used. + */ + let author: String? + + /** + The title used in the top bar of the website, next to the logo. + + This title can be HTML content, and only the root level value is used. + */ + let topBarTitle: String? + + /** + The url where the site will be deployed. + + This value is required to build absolute links for link previews. + + - Note: Only the root level value is used. + - Note: The path does not need to contain a trailing slash. + */ + let deployedBaseUrl: String? + + /** + The (start) date of the element. + + The date is printed on content pages and may also used for sorting elements, + depending on the `useManualSorting` property of the parent. + */ + let date: String? + + /** + The end date of the element. + + This property can be used to specify a date range for a content page. + */ + let endDate: String? + + /** + The deployment state of the page. + + - Note: This property defaults to ``PageState.standard` + */ + let state: String? + + /** + The sort index of the page for manual sorting. + + - Note: This property is only used (and must be set) if `useManualSorting` option of the parent is set. + */ + let sortIndex: Int? + + /** + All files which may occur in content but is stored externally. + + Missing files which would otherwise produce a warning are ignored when included here. + - Note: This property defaults to an empty set. + */ + let externalFiles: Set? + + /** + Specifies additional files which should be copied to the destination when generating the content. + - Note: This property defaults to an empty set. + */ + let requiredFiles: Set? + + /** + The style of thumbnail to use when generating overviews. + + - Note: This property is only relevant for sections. + - Note: This property is inherited from the parent if not specified. + */ + let thumbnailStyle: ThumbnailStyle? + + /** + Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`. + + - Note: This property is only relevant for sections. + - Note: This property defaults to `false` + */ + let useManualSorting: Bool? + + /** + The number of items to show when generating overviews of this element. + - Note: This property is only relevant for sections. + - Note: This property is inherited from the parent if not specified. + */ + let overviewItemCount: Int? + + /** + The localized metadata for each language. + */ + let languages: [LocalizedMetadata]? +} + +extension GenericMetadata: Codable { + + private static var knownKeyList: [CodingKeys] { + [ + .author, + .topBarTitle, + .deployedBaseUrl, + .date, + .endDate, + .state, + .sortIndex, + .externalFiles, + .requiredFiles, + .thumbnailStyle, + .useManualSorting, + .overviewItemCount, + .languages + ] + } + + static var knownKeys: Set { + Set(knownKeyList.map { $0.stringValue }) + } +} + +extension GenericMetadata { + + /** + Decode metadata in a folder. + + - Parameter path: The path to the element folder, relative to the source root + - Parameter context: The context for the element (validation, file access, etc.) + - Note: The decoding routine also checks for unknown properties, and writes them to the output. + */ + init?(path: String?, with context: Context) throws { + let source = path ?? "root" + let metadataPath = (path.unwrapped { $0 + "/" } ?? "") + GenericMetadata.metadataFileName + guard let data = try context.fileSystem.loadDataContent(inputPath: metadataPath) else { + return nil + } + + let decoder = JSONDecoder() + + let knownKeys = GenericMetadata.knownKeys + let knownLocalizedKeys = LocalizedMetadata.knownKeys + decoder.keyDecodingStrategy = .custom { keys in + let key = keys.last! + // Only one key means we are decoding the generic metadata + guard keys.count > 1 else { + if !knownKeys.contains(key.stringValue) { + context.validation.unknown(property: key.stringValue, source: source) + } + return key + } + // Two levels means we're decoding the localized metadata + if !knownLocalizedKeys.contains(key.stringValue) { + context.validation.unknown(property: key.stringValue, source: source) + } + return key + } + do { + self = try decoder.decode(from: data) + } catch { + context.validation.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error) + return nil + } + } +} diff --git a/WebsiteGenerator/Generic/PageState.swift b/WebsiteGenerator/Generic/PageState.swift new file mode 100644 index 0000000..74b52ed --- /dev/null +++ b/WebsiteGenerator/Generic/PageState.swift @@ -0,0 +1,18 @@ +import Foundation + +enum PageState: String { + /** + Generate the page, and show it in overviews of the parent. + */ + case standard + + /** + Generate the page, but don't provide links in overviews. + */ + case draft + + /** + Generate the page, but don't include it in overviews of the parent. + */ + case hide +} diff --git a/WebsiteGenerator/main.swift b/WebsiteGenerator/main.swift index 13a35d4..febde28 100644 --- a/WebsiteGenerator/main.swift +++ b/WebsiteGenerator/main.swift @@ -2,6 +2,26 @@ import Foundation let contentDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace") let outputDirectory = URL(fileURLWithPath: "/Users/ch/Projects/MakerSpace/Site") + +let context = Context(inputFolder: contentDirectory, outputFolder: outputDirectory) + +let siteData: Element +do { + guard let element = try Element(atRoot: contentDirectory, with: context) else { + exit(0) + } + siteData = element +} catch { + print(error) + exit(0) +} + +siteData.printTree() +context.fileSystem.printChangedFilesOverview() +context.fileSystem.didGenerateAllFiles() + +exit(0) + let files = FileProcessor( inputFolder: contentDirectory, outputFolder: outputDirectory)