Add first source scanning
This commit is contained in:
parent
7fe1865dfd
commit
06daa5e5fa
@ -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 = "<group>"; };
|
||||
E22E87B1289F296700E51191 /* ThumbnailInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThumbnailInfo.swift; sourceTree = "<group>"; };
|
||||
E22E87B5289FF67B00E51191 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||
E253C86828AFD86E0076B6D0 /* FileAccess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileAccess.swift; sourceTree = "<group>"; };
|
||||
E253C86A28AFE0980076B6D0 /* Context.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Context.swift; sourceTree = "<group>"; };
|
||||
E26555E328A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadataProvider.swift; sourceTree = "<group>"; };
|
||||
E2C5A5D428A0223C00102A25 /* HeaderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderTemplate.swift; sourceTree = "<group>"; };
|
||||
E2C5A5D628A022C500102A25 /* TemplateFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateFactory.swift; sourceTree = "<group>"; };
|
||||
@ -117,6 +129,14 @@
|
||||
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = "<group>"; };
|
||||
E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageVideoTemplate.swift; sourceTree = "<group>"; };
|
||||
E2F8FA2728ACD84400632026 /* VideoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoType.swift; sourceTree = "<group>"; };
|
||||
E2F8FA2C28AD2F5300632026 /* GenericMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GenericMetadata.swift; sourceTree = "<group>"; };
|
||||
E2F8FA2F28AD450B00632026 /* PageState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageState.swift; sourceTree = "<group>"; };
|
||||
E2F8FA3128AD456C00632026 /* GenericMetadata+Localized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GenericMetadata+Localized.swift"; sourceTree = "<group>"; };
|
||||
E2F8FA3328AD6F3400632026 /* Element.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Element.swift; sourceTree = "<group>"; };
|
||||
E2F8FA3528AE233600632026 /* Element+LocalizedMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Element+LocalizedMetadata.swift"; sourceTree = "<group>"; };
|
||||
E2F8FA3728AE27A500632026 /* ContentError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentError.swift; sourceTree = "<group>"; };
|
||||
E2F8FA3928AE313A00632026 /* ErrorOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorOutput.swift; sourceTree = "<group>"; };
|
||||
E2F8FA3B28AE685C00632026 /* Decodable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Decodable+Extensions.swift"; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
@ -257,6 +279,22 @@
|
||||
path = Filled;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = "<group>";
|
||||
};
|
||||
/* 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;
|
||||
};
|
||||
|
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "A4ABB69A-4D7A-40A2-832F-5B0B63325500"
|
||||
type = "1"
|
||||
version = "2.0">
|
||||
</Bucket>
|
@ -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?
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
8
WebsiteGenerator/Extensions/Decodable+Extensions.swift
Normal file
8
WebsiteGenerator/Extensions/Decodable+Extensions.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
extension JSONDecoder {
|
||||
|
||||
func decode<T>(from data: Data) throws -> T where T: Decodable {
|
||||
try self.decode(T.self, from: data)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
|
27
WebsiteGenerator/Generic/ContentError.swift
Normal file
27
WebsiteGenerator/Generic/ContentError.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
14
WebsiteGenerator/Generic/Context.swift
Normal file
14
WebsiteGenerator/Generic/Context.swift
Normal file
@ -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)
|
||||
}
|
||||
}
|
223
WebsiteGenerator/Generic/Element+LocalizedMetadata.swift
Normal file
223
WebsiteGenerator/Generic/Element+LocalizedMetadata.swift
Normal file
@ -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
|
||||
}
|
||||
}
|
237
WebsiteGenerator/Generic/Element.swift
Normal file
237
WebsiteGenerator/Generic/Element.swift
Normal file
@ -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<String>
|
||||
|
||||
/**
|
||||
Specifies additional files which should be copied to the destination when generating the content.
|
||||
- Note: This property defaults to an empty set.
|
||||
*/
|
||||
let requiredFiles: Set<String>
|
||||
|
||||
/**
|
||||
The style of thumbnail to use when generating overviews.
|
||||
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property is inherited from the parent if not specified.
|
||||
*/
|
||||
let thumbnailStyle: ThumbnailStyle
|
||||
|
||||
/**
|
||||
Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`.
|
||||
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property defaults to `false`
|
||||
*/
|
||||
let useManualSorting: Bool
|
||||
|
||||
/**
|
||||
The number of items to show when generating overviews of this element.
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property is inherited from the parent if not specified.
|
||||
*/
|
||||
let overviewItemCount: Int
|
||||
|
||||
/**
|
||||
The localized metadata for each language.
|
||||
*/
|
||||
let languages: [LocalizedMetadata]
|
||||
|
||||
/**
|
||||
All elements contained within the element.
|
||||
|
||||
If the element is a section, then this property contains the pages or subsections within.
|
||||
*/
|
||||
var elements: [Element] = []
|
||||
|
||||
/**
|
||||
The url of the element's folder in the source hierarchy.
|
||||
- Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property.
|
||||
*/
|
||||
let inputFolder: URL
|
||||
|
||||
/**
|
||||
The path to the element's folder in the source hierarchy (without a leading slash).
|
||||
*/
|
||||
let path: String
|
||||
|
||||
/**
|
||||
Create the root element of a site.
|
||||
|
||||
The root element will recursively move into subfolders and build the site content
|
||||
by looking for metadata files in each subfolder.
|
||||
- Parameter folder: The root folder of the site content.
|
||||
- Parameter context: The context to create the element (validation, file access, etc.)
|
||||
*/
|
||||
init?(atRoot folder: URL, with context: Context) throws {
|
||||
let validation = context.validation
|
||||
self.inputFolder = folder
|
||||
self.path = ""
|
||||
|
||||
let source = "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 + " ") }
|
||||
}
|
||||
}
|
137
WebsiteGenerator/Generic/ErrorOutput.swift
Normal file
137
WebsiteGenerator/Generic/ErrorOutput.swift
Normal file
@ -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<T, R>(_ value: Optional<T>, _ name: String, source: String) -> Optional<R> {
|
||||
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<T>(_ value: Optional<T>, name: String, source: String) -> Optional<T> {
|
||||
guard let value = value else {
|
||||
add(error: "Missing property '\(name)'", source: source)
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func unexpected<T>(_ value: Optional<T>, name: String, source: String) -> Optional<T> {
|
||||
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
|
||||
}()
|
||||
}
|
146
WebsiteGenerator/Generic/FileAccess.swift
Normal file
146
WebsiteGenerator/Generic/FileAccess.swift
Normal file
@ -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<String> = []
|
||||
|
||||
init(in root: URL, errorOutput: ErrorOutput) {
|
||||
self.sourceFolder = root
|
||||
self.errorOutput = errorOutput
|
||||
|
||||
loadSavedModificationTimes()
|
||||
}
|
||||
|
||||
private func loadSavedModificationTimes() {
|
||||
let url = modificationTimeCacheFile
|
||||
guard url.exists else {
|
||||
errorOutput.add(info: "No file modification times loaded, regarding all content as new", source: source)
|
||||
return
|
||||
}
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
} catch {
|
||||
errorOutput.add(
|
||||
warning: "File modification times data not read, regarding all content as new",
|
||||
source: source,
|
||||
error: error)
|
||||
return
|
||||
}
|
||||
do {
|
||||
self.sourceLastModifiedTimes = try JSONDecoder().decode(from: data)
|
||||
} catch {
|
||||
errorOutput.add(
|
||||
warning: "File modification times not decoded, regarding all content as new",
|
||||
source: source,
|
||||
error: error)
|
||||
try? url.delete()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private func didAccess(inputPath: String, modified lastModified: Date, source: String) {
|
||||
guard let previousDate = sourceLastModifiedTimes[inputPath] else {
|
||||
// File not processed before, so mark as changed
|
||||
changedFiles.insert(inputPath)
|
||||
return
|
||||
}
|
||||
guard lastModified > previousDate else {
|
||||
// File is unchanged
|
||||
return
|
||||
}
|
||||
changedFiles.insert(inputPath)
|
||||
}
|
||||
|
||||
private func lastModifiedTime(of url: URL) -> Date? {
|
||||
guard url.exists else {
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||
guard let date = attributes[.modificationDate] as? Date else {
|
||||
errorOutput.add(warning: "Failed to read modification time of \(url.path)", source: source)
|
||||
return nil
|
||||
}
|
||||
return date
|
||||
} catch {
|
||||
errorOutput.add(warning: "Failed to read file attributes of \(url.path)", source: source)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func loadStringContent(inputPath: String) throws -> String? {
|
||||
try load(inputPath: inputPath, String.init)
|
||||
}
|
||||
|
||||
func loadDataContent(inputPath: String) throws -> Data? {
|
||||
try load(inputPath: inputPath) { try Data(contentsOf: $0) }
|
||||
}
|
||||
|
||||
private func load<T>(inputPath: String, _ closure: (URL) throws -> T) rethrows -> T? {
|
||||
let url = sourceFolder.appendingPathComponent(inputPath)
|
||||
guard let modifiedDate = lastModifiedTime(of: url) else {
|
||||
sourceLastModifiedTimes[inputPath] = nil
|
||||
return nil
|
||||
}
|
||||
do {
|
||||
let content = try closure(url)
|
||||
didAccess(inputPath: inputPath, modified: modifiedDate, source: source)
|
||||
return content
|
||||
} catch {
|
||||
throw FileAccessError.failedToReadFile(inputPath, error)
|
||||
}
|
||||
}
|
||||
|
||||
func didGenerateAllFiles() {
|
||||
for file in changedFiles {
|
||||
let url = sourceFolder.appendingPathComponent(file)
|
||||
guard let date = lastModifiedTime(of: url) else {
|
||||
continue
|
||||
}
|
||||
sourceLastModifiedTimes[file] = date
|
||||
}
|
||||
do {
|
||||
let data = try JSONEncoder().encode(sourceLastModifiedTimes)
|
||||
try data.write(to: modificationTimeCacheFile)
|
||||
} catch {
|
||||
errorOutput.add(warning: "Failed to save modification times", source: source, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func printChangedFilesOverview() {
|
||||
let count = changedFiles.count
|
||||
guard count > 0 else {
|
||||
print("No files modified")
|
||||
return
|
||||
}
|
||||
print("\(count) files modified:")
|
||||
changedFiles.prefix(10).forEach { print(" " + $0) }
|
||||
if count > 10 {
|
||||
print(" ...")
|
||||
}
|
||||
}
|
||||
}
|
203
WebsiteGenerator/Generic/GenericMetadata+Localized.swift
Normal file
203
WebsiteGenerator/Generic/GenericMetadata+Localized.swift
Normal file
@ -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<String> {
|
||||
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: "")
|
||||
}
|
||||
}
|
176
WebsiteGenerator/Generic/GenericMetadata.swift
Normal file
176
WebsiteGenerator/Generic/GenericMetadata.swift
Normal file
@ -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<String>?
|
||||
|
||||
/**
|
||||
Specifies additional files which should be copied to the destination when generating the content.
|
||||
- Note: This property defaults to an empty set.
|
||||
*/
|
||||
let requiredFiles: Set<String>?
|
||||
|
||||
/**
|
||||
The style of thumbnail to use when generating overviews.
|
||||
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property is inherited from the parent if not specified.
|
||||
*/
|
||||
let thumbnailStyle: ThumbnailStyle?
|
||||
|
||||
/**
|
||||
Sort the child elements by their `sortIndex` property when generating overviews, instead of using the `date`.
|
||||
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property defaults to `false`
|
||||
*/
|
||||
let useManualSorting: Bool?
|
||||
|
||||
/**
|
||||
The number of items to show when generating overviews of this element.
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property is inherited from the parent if not specified.
|
||||
*/
|
||||
let overviewItemCount: Int?
|
||||
|
||||
/**
|
||||
The localized metadata for each language.
|
||||
*/
|
||||
let languages: [LocalizedMetadata]?
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
18
WebsiteGenerator/Generic/PageState.swift
Normal file
18
WebsiteGenerator/Generic/PageState.swift
Normal file
@ -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
|
||||
}
|
@ -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)
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user