Add first source scanning

This commit is contained in:
Christoph Hagen 2022-08-19 18:05:06 +02:00
parent 7fe1865dfd
commit 06daa5e5fa
17 changed files with 1287 additions and 5 deletions

View File

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

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Bucket
uuid = "A4ABB69A-4D7A-40A2-832F-5B0B63325500"
type = "1"
version = "2.0">
</Bucket>

View File

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

View File

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

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

View File

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