Update generation

- Move to global objects for files and validation
- Only write changed files
- Check images for changes before scaling
- Simplify code
This commit is contained in:
Christoph Hagen
2022-08-26 17:40:51 +02:00
parent 91d5bcb66d
commit 80d3c08a93
54 changed files with 1344 additions and 2419 deletions

View File

@ -0,0 +1,229 @@
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, if available.
*/
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.
*/
let backLinkText: String
/**
The text on the back navigation link of the **parent** element.
This text appears on the section page, but not on the pages contained within the section.
*/
let parentBackLinkText: 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) {
// 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 = log
.required(data.language, name: "language", source: source)
.ifNil(markAsIncomplete) ?? ""
self.title = log
.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 = log
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
self.linkPreviewDescription = log
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
self.backLinkText = log
.required(data.backLinkText, name: "backLinkText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.parentBackLinkText = "" // Root has no parent
self.placeholderTitle = log
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
.ifNil(markAsIncomplete) ?? ""
self.placeholderText = log
.required(data.placeholderText, name: "placeholderText", source: source)
.ifNil(markAsIncomplete) ?? ""
self.titleSuffix = data.titleSuffix
self.thumbnailSuffix = log.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
self.cornerText = log.unused(data.cornerText, "cornerText", source: source)
self.externalUrl = log.unexpected(data.externalUrl, name: "externalUrl", source: source)
guard isComplete else {
return nil
}
}
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata) {
// 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 = log
.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 = log
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
let linkPreviewDescription = data.linkPreviewDescription ?? data.subtitle ?? data.description
self.linkPreviewDescription = log
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
.ifNil(markAsIncomplete) ?? ""
self.moreLinkText = log.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
self.backLinkText = data.backLinkText ?? parent.backLinkText
self.parentBackLinkText = 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,429 @@
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
/**
Indicate that no header should be generated automatically.
This option assumes that custom header code is present in the page source files
- Note: If not specified, this property defaults to `false`.
*/
let useCustomHeader: Bool
/**
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.
- Note: Uses global objects.
*/
init?(atRoot folder: URL) throws {
self.inputFolder = folder
self.path = ""
let source = GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else {
return nil
}
self.author = log.required(metadata.author, name: "author", source: source) ?? "author"
self.topBarTitle = log
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
self.deployedBaseUrl = log
.required(metadata.deployedBaseUrl, name: "deployedBaseUrl", source: source) ?? "https://example.com"
self.date = log.unused(metadata.date, "date", source: source)
self.endDate = log.unused(metadata.endDate, "endDate", source: source)
self.state = log.state(metadata.state, source: source)
self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source)
self.externalFiles = metadata.externalFiles ?? []
self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root
self.thumbnailStyle = log.unused(metadata.thumbnailStyle, "thumbnailStyle", source: source) ?? .large
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source) ?? true
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
self.useCustomHeader = metadata.useCustomHeader ?? false
self.languages = log.required(metadata.languages, name: "languages", source: source)?
.compactMap { language in
.init(atRoot: folder, data: language)
} ?? []
try self.readElements(in: folder, source: nil)
}
mutating func readElements(in folder: URL, source: String?) throws {
let subFolders: [URL]
do {
subFolders = try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.isDirectory }
} catch {
log.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, path: s)
}
}
init?(parent: Element, folder: URL, path: String) throws {
self.inputFolder = folder
self.path = path
let source = path + "/" + GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else {
return nil
}
self.author = metadata.author ?? parent.author
self.topBarTitle = log
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
self.deployedBaseUrl = log
.unused(metadata.deployedBaseUrl, "deployedBaseUrl", source: source) ?? parent.deployedBaseUrl
let date = log.date(from: metadata.date, property: "date", source: source).ifNil {
if !parent.useManualSorting {
log.add(error: "No 'date', but parent defines 'useManualSorting' = false", source: source)
}
}
self.date = date
self.endDate = log.date(from: metadata.endDate, property: "endDate", source: source).ifNotNil {
if date == nil {
log.add(warning: "Set 'endDate', but no 'date'", source: source)
}
}
self.state = log.state(metadata.state, source: source)
self.sortIndex = metadata.sortIndex.ifNil {
if parent.useManualSorting {
log.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 = Set((metadata.requiredFiles ?? []).map { path + "/" + $0 })
self.thumbnailStyle = log.thumbnailStyle(metadata.thumbnailStyle, source: source)
self.useManualSorting = metadata.useManualSorting ?? false
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
self.useCustomHeader = metadata.useCustomHeader ?? false
self.languages = parent.languages.compactMap { parentData in
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
log.add(info: "Language '\(parentData.language)' not found", source: source)
return nil
}
return .init(folder: folder, data: data, source: source, parent: parentData)
}
// Check that each 'language' tag is present, and that all languages appear in the parent
log.required(metadata.languages, name: "languages", source: source)?
.compactMap { log.required($0.language, name: "language", source: source) }
.filter { language in
!parent.languages.contains { $0.language == language }
}
.forEach {
log.add(warning: "Language '\($0)' not found in parent, so not generated", source: source)
}
try self.readElements(in: folder, source: path)
}
}
// MARK: Paths
extension Element {
var containsElements: Bool {
!elements.isEmpty
}
var hasNestingElements: Bool {
elements.contains { $0.containsElements }
}
var sortedItems: [Element] {
if useManualSorting {
return elements.sorted { $0.sortIndex! < $1.sortIndex! }
}
return elements.sorted { $0.date! > $1.date! }
}
/**
The url of the top-level section of the element.
*/
func sectionUrl(for language: String) -> String {
path.components(separatedBy: "/").first! + "/\(language).html"
}
/**
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
This function is used to copy required input files and to generate images
*/
func pathRelativeToRootForContainedInputFile(_ filePath: String) -> String {
guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else {
return filePath
}
return "\(path)/\(filePath)"
}
func relativePathToFileWithPath(_ filePath: String) -> String {
guard path != "" else {
return filePath
}
guard filePath.hasPrefix(path) else {
return filePath
}
return filePath.replacingOccurrences(of: path + "/", with: "")
}
}
// MARK: Accessing localizations
extension Element {
/**
Get the full path of the thumbnail image for the language (relative to the root folder).
*/
func thumbnailFilePath(for language: String) -> String {
guard let thumbnailFile = Element.findThumbnail(for: language, in: inputFolder) else {
fatalError()
}
return pathRelativeToRootForContainedInputFile(thumbnailFile)
}
func fullPageUrl(for language: String) -> String {
localized(for: language).externalUrl ?? localizedPath(for: language)
}
func localized(for language: String) -> LocalizedMetadata {
languages.first { $0.language == language }!
}
func title(for language: String) -> String {
localized(for: language).title
}
/**
Get the back link text for the element.
This text is the one printed for pages of the element, which uses the back text specified by the parent.
*/
func backLinkText(for language: String) -> String {
localized(for: language).parentBackLinkText
}
/**
The optional text to display in a thumbnail corner.
- Note: This text is only displayed for large thumbnails.
*/
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
/**
Returns the full path (relative to the site root for a page of the element in the given language.
*/
func localizedPath(for language: String) -> String {
path != "" ? "\(path)/\(language).html" : "\(language).html"
}
/**
Get the next language to switch to with the language button.
*/
func nextLanguage(for languageIdentifier: String) -> String? {
let langs = languages.map { $0.language }
guard let index = langs.firstIndex(of: languageIdentifier) else {
return nil
}
for i in 1..<langs.count {
let next = langs[(index + i) % langs.count]
guard hasContent(for: next) else {
continue
}
guard next != languageIdentifier else {
return nil
}
return next
}
return nil
}
func linkPreviewImage(for language: String) -> String? {
localized(for: language).linkPreviewImage
}
}
// MARK: Page content
extension Element {
var isExternalPage: Bool {
languages.contains { $0.externalUrl != nil }
}
/**
Get the url of the content markdown file for a language.
To check if the file also exists, use `existingContentUrl(for:)`
*/
private func contentUrl(for language: String) -> URL {
inputFolder.appendingPathComponent("\(language).md")
}
/**
Get the url of existing markdown content for a language.
*/
private func existingContentUrl(for language: String) -> URL? {
let url = contentUrl(for: language)
guard url.exists else {
return nil
}
return url
}
private func hasContent(for language: String) -> Bool {
existingContentUrl(for: language) != nil
}
}
// MARK: Header and Footer
extension Element {
private var additionalHeadContentPath: String {
path + "/head.html"
}
func customHeadContent() -> String? {
files.contentOfOptionalFile(atPath: additionalHeadContentPath, source: path)
}
private var additionalFooterContentPath: String {
path + "/footer.html"
}
func customFooterContent() -> String? {
files.contentOfOptionalFile(atPath: additionalFooterContentPath, source: path)
}
}
// MARK: Debug
extension Element {
func printTree(indentation: String = "") {
print(indentation + "/" + path)
elements.forEach { $0.printTree(indentation: indentation + " ") }
}
}

View File

@ -0,0 +1,207 @@
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,
.titleSuffix,
.thumbnailSuffix,
.cornerText,
.externalUrl,
]
}
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,185 @@
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: String?
/**
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?
/**
Indicate that no header should be generated automatically.
This option assumes that custom header code is present in the page source files
- Note: If not specified, this property defaults to `false`.
*/
let useCustomHeader: Bool?
/**
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,
.useCustomHeader,
.languages,
]
}
static var knownKeys: Set<String> {
Set(knownKeyList.map { $0.stringValue })
}
}
extension GenericMetadata {
/**
Decode metadata in a folder.
- Parameter data: The binary data of the metadata file.
- Parameter source: The path to the metadata file, relative to the source root
- Note: The decoding routine also checks for unknown properties, and writes them to the output.
- Note: Uses global objects
*/
init?(source: String) {
guard let data = files.dataOfOptionalFile(atPath: source, source: source) 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) {
log.unknown(property: key.stringValue, source: source)
}
return key
}
// Two levels means we're decoding the localized metadata
if !knownLocalizedKeys.contains(key.stringValue) {
log.unknown(property: key.stringValue, source: source)
}
return key
}
do {
self = try decoder.decode(from: data)
} catch {
print("Here \(data)")
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
return nil
}
}
}

View File

@ -1,119 +0,0 @@
import Foundation
protocol LanguageIdentifiable {
var languageIdentifier: String { get }
var title: String { get }
}
protocol LanguageContainer {
associatedtype LocalizedContainer: LanguageIdentifiable
var languages: [LocalizedContainer] { get }
}
protocol LocalizedMetadataContainer {
associatedtype MetadataType: LanguageContainer
var metadata: MetadataType { get }
func hasContent(for language: String) -> Bool
}
// MARK: Default implementations
extension LocalizedMetadataContainer {
func hasContent(for language: String) -> Bool {
true
}
}
// MARK: Extensions
extension LocalizedMetadataContainer {
func localized(for language: String) -> MetadataType.LocalizedContainer {
metadata.localized(for: language)
}
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String {
localized(for: language).title
}
func nextLanguage(for languageIdentifier: String) -> String? {
let langs = metadata.languages.map { $0.languageIdentifier }
guard let index = langs.firstIndex(of: languageIdentifier) else {
return nil
}
for i in 1..<langs.count {
let next = langs[(index + i) % langs.count]
guard hasContent(for: next) else {
continue
}
guard next != languageIdentifier else {
return nil
}
return next
}
return nil
}
}
extension LanguageContainer {
var languageIdentifiers: [String] {
languages.map { $0.languageIdentifier }
}
#warning("Throw better error for missing language")
func localized(for language: String) -> LocalizedContainer {
languages.first { $0.languageIdentifier == language }!
}
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String {
localized(for: language).title
}
}
extension LocalizedMetadataContainer where Self: SiteElement, Self.MetadataType.LocalizedContainer: LinkPreviewMetadataProvider {
private func linkPreviewImageFileName(for language: String) -> String? {
if let fileName = localized(for: language).linkPreview?.image {
return fileName
}
// Check for the existence of a localized thumbnail
let fileName = Self.thumbnailFileNameLocalized(for: language)
if inputFolder.appendingPathComponent(fileName).exists {
return fileName
}
let defaultThumbnail = Self.defaultThumbnailFileName
if inputFolder.appendingPathComponent(defaultThumbnail).exists {
return defaultThumbnail
}
return nil
}
func linkPreviewImage(for language: String) -> String? {
guard let fileName = linkPreviewImageFileName(for: language) else {
return nil
}
return "/\(path)/\(fileName)"
}
}

View File

@ -1,41 +0,0 @@
import Foundation
/**
Localized configuration data for link previews of site elements.
This struct is embedded in localized metadata and intended to be filled in the JSON source.
*/
struct LinkPreviewMetadata {
/**
The title to use for the link preview.
If `nil` is specified, then the localized element `title` is used.
*/
let title: String?
/**
The file name of the link preview image.
- Note: The image must be located in the element folder.
- Note: If `nil` is specified, then the (localized) thumbnail is used.
*/
let image: String?
/**
The description text for the link preview.
- Note: If `nil` is specified, then first the (localized) element `subtitle` is used.
If this is `nil` too, then the localized `description` of the element is used.
*/
let description: String?
}
extension LinkPreviewMetadata: Codable { }
extension LinkPreviewMetadata {
static var initial: LinkPreviewMetadata {
.init(title: nil,
image: nil,
description: "The page description for link previews")
}
}

View File

@ -1,23 +0,0 @@
import Foundation
protocol LinkPreviewMetadataProvider {
var linkPreview: LinkPreviewMetadata? { get }
var title: String { get }
var subtitle: String? { get }
var description: String { get }
}
extension LinkPreviewMetadataProvider {
var linkPreviewTitle: String {
linkPreview?.title ?? title
}
var linkPreviewDescription: String {
linkPreview?.description ?? subtitle ?? description
}
}

View File

@ -1,29 +0,0 @@
import Foundation
protocol Metadata: Codable {
static var fileName: String { get }
static var initial: Self { get }
}
extension Metadata {
static func url(in folder: URL) -> URL {
folder.appendingPathComponent(fileName)
}
static func exists(in folder: URL) -> Bool {
url(in: folder).exists
}
init?(in folder: URL) throws {
let metadataUrl = Self.url(in: folder)
guard metadataUrl.exists else {
try Self.initial.writeJSON(to: metadataUrl)
print("Created metadata in \(folder)")
return nil
}
try self.init(decodeFrom: metadataUrl)
}
}

View File

@ -1,62 +0,0 @@
import Foundation
extension Page {
struct LocalizedMetadata {
let id: String
let title: String
#warning("Generate title suffix")
let titleSuffix: String?
let linkPreview: LinkPreviewMetadata?
let subtitle: String?
#warning("Generate thumbnail suffix")
let thumbnailSuffix: String?
let cornerText: String?
/**
The external url to use instead of automatically generating the page.
This property can be used for links to other parts of the site, like additional services.
It can also be set to manually write a page.
*/
let externalUrl: String?
}
}
extension Page.LocalizedMetadata: Codable {
}
extension Page.LocalizedMetadata: LanguageIdentifiable {
var languageIdentifier: String {
id
}
}
extension Page.LocalizedMetadata {
static var initial: Page.LocalizedMetadata {
.init(id: "en",
title: "Page title",
titleSuffix: nil,
linkPreview: .initial,
subtitle: "Some text below the title",
thumbnailSuffix: "Project",
cornerText: nil,
externalUrl: nil)
}
}
extension Page.LocalizedMetadata: LinkPreviewMetadataProvider {
var description: String { subtitle ?? title }
}

View File

@ -1,107 +0,0 @@
import Foundation
extension Page {
struct Metadata {
let date: Date
let endDate: Date?
let author: String?
let isDraft: Bool
let sortIndex: Int?
let languages: [LocalizedMetadata]
#warning("Add hideFromOverview property")
let requiredFiles: [String]
/**
Indicate that no header should be generated automatically.
This option assumes that custom header code is present in the page source files
- Note: If not specified, this property defaults to `false`.
*/
let useCustomHeader: Bool
#warning("Add files for which errors are ignored when missing")
}
}
extension Page.Metadata: Metadata {
static let fileName = "page.json"
static var initial: Page.Metadata {
.init(
date: .now,
endDate: .now,
author: nil,
isDraft: true,
sortIndex: 0,
languages: [.initial],
requiredFiles: [],
useCustomHeader: false)
}
}
extension Page.Metadata: LanguageContainer {
}
extension Page.Metadata: Codable {
enum CodingKeys: CodingKey {
case date
case endDate
case author
case isDraft
case sortIndex
case languages
case requiredFiles
case useCustomHeader
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let dateString = Page.metadataDateFormatter.string(from: date)
try container.encode(dateString, forKey: .date)
if let date = endDate {
let endDateString = Page.metadataDateFormatter.string(from: date)
try container.encode(endDateString, forKey: .endDate)
}
try container.encodeIfPresent(author, forKey: .author)
try container.encode(isDraft, forKey: .isDraft)
try container.encodeIfPresent(sortIndex, forKey: .sortIndex)
try container.encode(languages, forKey: .languages)
if !requiredFiles.isEmpty {
try container.encode(requiredFiles, forKey: .requiredFiles)
}
if useCustomHeader {
try container.encode(true, forKey: .useCustomHeader)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let dateString = try container.decode(String.self, forKey: .date)
self.date = try Page.metadataDateFormatter.date(from: dateString)
.unwrap(or: .invalidDateInPageMetadata(dateString))
self.author = try container.decodeIfPresent(String.self, forKey: .author)
self.languages = try container.decode([Page.LocalizedMetadata].self, forKey: .languages)
self.isDraft = try container.decodeIfPresent(Bool.self, forKey: .isDraft) ?? false
self.sortIndex = try container.decodeIfPresent(Int.self, forKey: .sortIndex)
if let endDateString = try container.decodeIfPresent(String.self, forKey: .endDate) {
self.endDate = try Page.metadataDateFormatter.date(from: endDateString)
.unwrap(or: .invalidDateInPageMetadata(endDateString))
} else {
self.endDate = nil
}
self.requiredFiles = try container.decodeIfPresent([String].self, forKey: .requiredFiles) ?? []
self.useCustomHeader = try container.decodeIfPresent(Bool.self, forKey: .useCustomHeader) ?? false
}
}

View File

@ -1,83 +0,0 @@
import Foundation
struct Page {
let metadata: Metadata
/// The input folder where the page data is stored
let inputFolder: URL
let path: String
init?(folder: URL, path: String) throws {
self.path = path
guard let metadata = try Metadata(in: folder) else {
return nil
}
self.inputFolder = folder
self.metadata = metadata
}
}
extension Page {
static let metadataDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yy"
return df
}()
}
extension Page: SiteElement {
var sortIndex: Int? {
metadata.sortIndex
}
var sortDate: Date? {
metadata.date
}
var elements: [SiteElement] { [] }
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
var isExternalPage: Bool {
metadata.languages.contains { $0.externalUrl != nil }
}
func fullPageUrl(for language: String) -> String {
localized(for: language).externalUrl ?? "\(path)/\(language).html"
}
}
extension Page: LocalizedMetadataContainer {
/**
Get the url of the content markdown file for a language.
To check if the file also exists, use `existingContentUrl(for:)`
*/
func contentUrl(for language: String) -> URL {
inputFolder.appendingPathComponent("\(language).md")
}
/**
Get the url of existing markdown content for a language.
*/
func existingContentUrl(for language: String) -> URL? {
let url = contentUrl(for: language)
guard url.exists else {
return nil
}
return url
}
func hasContent(for language: String) -> Bool {
existingContentUrl(for: language) != nil
}
}

View File

@ -0,0 +1,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

@ -1,65 +0,0 @@
import Foundation
extension Section {
struct LocalizedMetadata {
let id: String
let title: String
let subtitle: String?
let description: String
/**
The text on the link to show the section page when previewing multiple sections on an overview page.
*/
let moreLinkTitle: String
/**
An optional text to display in the corner of the section thumbnail.
Can be used to show things like "new", "draft", etc.
*/
let cornerText: String?
let linkPreview: LinkPreviewMetadata?
/**
The text on the back navigation link of contained elements.
This text does not appear on the section page, but on the pages contained within the section.
*/
let backLinkText: String?
}
}
extension Section.LocalizedMetadata: Codable {
}
extension Section.LocalizedMetadata: LanguageIdentifiable {
var languageIdentifier: String {
id
}
}
extension Section.LocalizedMetadata {
static var initial: Section.LocalizedMetadata {
.init(id: "en",
title: "Section title",
subtitle: "Tag line below the title",
description: "The short text below the tagline on the section overview page",
moreLinkTitle: "More section items",
cornerText: nil,
linkPreview: .initial,
backLinkText: "Back to section")
}
}
extension Section.LocalizedMetadata: LinkPreviewMetadataProvider {
}

View File

@ -1,79 +0,0 @@
import Foundation
extension Section {
static let defaultSectionOverviewItemCount = 6
struct Metadata {
let thumbnailStyle: ThumbnailStyle
let sortByMostRecent: Bool
let sortIndex: Int?
let date: Date?
let languages: [LocalizedMetadata]
let sectionOverviewItemCount: Int
}
}
extension Section.Metadata: Metadata {
static let fileName = "section.json"
static var initial: Section.Metadata {
.init(thumbnailStyle: .large,
sortByMostRecent: true,
sortIndex: nil,
date: nil,
languages: [.initial],
sectionOverviewItemCount: 6)
}
}
extension Section.Metadata: LanguageContainer {
}
extension Section.Metadata: Codable {
enum CodingKeys: CodingKey {
case thumbnailStyle
case sortByMostRecent
case sortIndex
case date
case languages
case sectionOverviewItemCount
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(thumbnailStyle, forKey: .thumbnailStyle)
try container.encode(sortByMostRecent, forKey: .sortByMostRecent)
try container.encodeIfPresent(sortIndex, forKey: .sortIndex)
try container.encode(languages, forKey: .languages)
if let date = date {
let dateString = Page.metadataDateFormatter.string(from: date)
try container.encode(dateString, forKey: .date)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.thumbnailStyle = try container.decode(ThumbnailStyle.self, forKey: .thumbnailStyle)
self.sortByMostRecent = try container.decode(Bool.self, forKey: .sortByMostRecent)
self.sortIndex = try container.decodeIfPresent(Int.self, forKey: .sortIndex)
self.languages = try container.decode([Section.LocalizedMetadata].self, forKey: .languages)
if let dateString = try container.decodeIfPresent(String.self, forKey: .date) {
self.date = try Page.metadataDateFormatter.date(from: dateString)
.unwrap(or: .invalidDateInPageMetadata(dateString))
} else {
self.date = nil
}
self.sectionOverviewItemCount = try container
.decodeIfPresent(Int.self, forKey: .sectionOverviewItemCount) ?? Section.defaultSectionOverviewItemCount
}
}

View File

@ -1,74 +0,0 @@
import Foundation
struct Section {
let metadata: Metadata
let inputFolder: URL
let elements: [SiteElement]
/// The path to get to the section from the root folder (no leading slash)
let path: String
var folderName: String {
inputFolder.lastPathComponent
}
var sortedItems: [SiteElement] {
guard metadata.sortByMostRecent else {
return elements.sorted { $0.sortIndex! < $1.sortIndex! }
}
return elements.sorted { $0.sortDate! > $1.sortDate! }
}
init?(folder: URL, path: String) throws {
self.path = path
guard let metadata = try Metadata(in: folder) else {
return nil
}
self.metadata = metadata
self.inputFolder = folder
let elements: [SiteElement] = try FileSystem.folders(in: folder)
.compactMap {
let sectionPath = "\(path)/\($0.lastPathComponent)"
if Page.Metadata.exists(in: $0) {
return try Page(folder: $0, path: sectionPath)
}
if Section.Metadata.exists(in: $0) {
return try Section(folder: $0, path: sectionPath)
}
return nil
}
if metadata.sortByMostRecent {
self.elements = elements.sorted { $0.sortDate! > $1.sortDate! }
} else {
self.elements = elements.sorted { $0.sortIndex! < $1.sortIndex! }
}
#warning("Verify that all sort indices or sort dates are present")
print("Section \(folderName): \(elements.count) pages")
}
}
extension Section: SiteElement {
var sortIndex: Int? {
metadata.sortIndex
}
var sortDate: Date? {
metadata.date
}
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
func backLinkText(for language: String) -> String? {
localized(for: language).backLinkText
}
}
extension Section: LocalizedMetadataContainer {
}

View File

@ -1,71 +0,0 @@
import Foundation
extension Site {
struct LocalizedMetadata {
let languageIdentifier: String
let linkPreview: LinkPreviewMetadata?
let title: String
let subtitle: String?
let description: String
/**
The text on the back navigation link of contained elements.
This text does not appear on the section page, but on the pages contained within the section.
*/
let backLinkText: String?
/**
The back text to use for element which don't specify a `backLinkText` themselves.
*/
let defaultBackLinkText: String
/**
The text to show as a title for placeholder boxes
Placeholders are included in missing pages.
*/
let placeholderTitle: String
/**
The text to show as a description for placeholder boxes
Placeholders are included in missing pages.
*/
let placeholderText: String
}
}
extension Site.LocalizedMetadata: Codable {
}
extension Site.LocalizedMetadata: LanguageIdentifiable {
}
extension Site.LocalizedMetadata {
static var initial: Site.LocalizedMetadata {
.init(
languageIdentifier: "en",
linkPreview: .initial,
title: "Website name on front page",
subtitle: "Tag line on front page",
description: "Some text below the tag line on the title page",
backLinkText: "Back to start",
defaultBackLinkText: "Back",
placeholderTitle: "Content missing",
placeholderText: "This page is incomplete. Content will be added in the coming days.")
}
}
extension Site.LocalizedMetadata: LinkPreviewMetadataProvider {
}

View File

@ -1,48 +0,0 @@
import Foundation
extension Site {
struct Metadata {
let author: String
let ignoredSubFolders: Set<String>
let topBarTitle: String?
/**
The url where the site will be deployed.
This value is required to build absolute links for link previews.
- Note: The path does not need to contain a trailing slash.
*/
let deployedBaseUrl: String
let languages: [LocalizedMetadata]
static func write(to url: URL) throws {
try Metadata.initial.writeJSON(to: url)
}
}
}
extension Site.Metadata: LanguageContainer {
}
extension Site.Metadata: Codable {
}
extension Site.Metadata: Metadata {
static let fileName = "site.json"
static var initial: Self {
.init(author: "Author",
ignoredSubFolders: ["templates"],
topBarTitle: "<b>Title</b>",
deployedBaseUrl: "http://example.com",
languages: [.initial])
}
}

View File

@ -1,54 +0,0 @@
import Foundation
struct Site {
static let linkPreviewDesiredImageWidth = 1600
let elements: [SiteElement]
let metadata: Metadata
let inputFolder: URL
init?(folder: URL) throws {
self.inputFolder = folder
guard let metadata = try Metadata(in: folder) else {
return nil
}
guard !metadata.languages.isEmpty else {
throw GenerationError.invalidLanguageSpecification("No languages specified in site.json")
}
self.metadata = metadata
self.elements = try FileSystem.folders(in: folder)
.filter { !metadata.ignoredSubFolders.contains($0.lastPathComponent) }
.compactMap { sectionUrl in
return try Section(
folder: sectionUrl, path: sectionUrl.lastPathComponent)
}
print("Loaded site with \(elements.count) sections and \(metadata.languages.count) languages")
// Create example metadata
//_ = try? Page.Metadata(in: folder)
//_ = try? Section.Metadata(in: folder)
}
}
extension Site: LocalizedMetadataContainer {
}
extension Site: SiteElement {
var sortIndex: Int? { 0 }
var sortDate: Date? { nil }
var path: String { "" }
func cornerText(for language: String) -> String? { nil }
func backLinkText(for language: String) throws -> String? {
localized(for: language).backLinkText
}
}

View File

@ -1,199 +0,0 @@
import Foundation
protocol SiteElement {
/**
The sort index for the element when manual sorting is specified for the parent.
- Note: Elements are sorted in ascending order.
*/
var sortIndex: Int? { get }
/**
The date used for sorting of the element, if automatic sorting is specified by the parent.
- Note: Elements are sorted by newest first.
*/
var sortDate: Date? { get }
/**
The path to the element's folder in the source hierarchy (without a leading slash).
*/
var path: String { get }
/**
The url of the element's folder in the source hierarchy.
- Note: This property is essentially the root folder of the site, appended with the value of the ``path`` property.
*/
var inputFolder: URL { get }
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String
/**
The optional text to display in a thumbnail corner.
- Note: This text is only displayed for large thumbnails.
*/
func cornerText(for language: String) -> String?
/**
The url to the element in the given language.
If the `externalUrl` property is not set for the page metadata in the given language, then the standard path is returned.
- If this value starts with a slash, it is considered an absolute url to the same domain
- If the value starts with `http://` or `https://` it is considered an external url
- Otherwise the value is treated as a path from the root of the site.
*/
func fullPageUrl(for language: String) -> String
/**
All elements contained within the element.
If the element is a section, then this property contains the pages within.
*/
var elements: [SiteElement] { get }
func backLinkText(for language: String) throws -> String?
}
extension SiteElement {
func fullPageUrl(for language: String) -> String {
localizedPath(for: language)
}
}
extension SiteElement {
/**
The id of the section to which this element contains.
This property is used to highlight the active section in the top bar.
The section id is the folder name of the top-level section
*/
var sectionId: String {
path.components(separatedBy: "/").first!
}
static var defaultThumbnailFileName: String { "thumbnail.jpg" }
static func thumbnailFileNameLocalized(for language: String) -> String {
defaultThumbnailFileName.insert("-\(language)", beforeLast: ".")
}
var containedFolder: String {
inputFolder.lastPathComponent
}
var containsElements: Bool {
!elements.isEmpty
}
var hasNestingElements: Bool {
elements.contains { $0.containsElements }
}
/**
Get the full path of the thumbnail image for the language (relative to the root folder).
*/
func thumbnailFilePath(for language: String) -> String {
let specificImageName = Self.thumbnailFileNameLocalized(for: language)
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
guard specificImageUrl.exists else {
return "\(path)/\(Self.defaultThumbnailFileName)"
}
return "\(path)/\(specificImageName)"
}
/**
Gets the thumbnail image for the element.
If a localized thumbnail exists, then this image name is returned.
*/
func thumbnailName(for language: String) -> String {
let specificImageName = "thumbnail-\(language).jpg"
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
guard specificImageUrl.exists else {
return "\(inputFolder.lastPathComponent)/thumbnail.jpg"
}
return "\(inputFolder.lastPathComponent)/\(specificImageName)"
}
/**
Create an absolute path (relative to the root directory) for a file contained in the elements folder.
This function is used to copy required input files and to generate images
*/
func pathRelativeToRootForContainedInputFile(_ filePath: String) -> String {
guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else {
return filePath
}
return "\(path)/\(filePath)"
}
func backLinkText(for language: String) throws -> String? { nil }
/**
Returns the full path (relative to the site root for a page of the element in the given language.
*/
func localizedPath(for language: String) -> String {
path != "" ? "\(path)/\(language).html" : "\(language).html"
}
func relativePathToFileWithPath(_ filePath: String) -> String {
guard path != "" else {
return filePath
}
guard filePath.hasPrefix(path) else {
return filePath
}
return filePath.replacingOccurrences(of: path + "/", with: "")
}
private var additionalHeadContentUrl: URL {
inputFolder.appendingPathComponent("head.html")
}
var hasAdditionalHeadContent: Bool {
additionalHeadContentUrl.exists
}
func customHeadContent() throws -> String? {
let url = additionalHeadContentUrl
guard url.exists else {
return nil
}
return try wrap(.failedToOpenFile(url.path)) {
try String(contentsOf: url)
}
}
private var additionalFooterContentUrl: URL {
inputFolder.appendingPathComponent("footer.html")
}
var hasAdditionalFooterContent: Bool {
additionalFooterContentUrl.exists
}
func customFooterContent() throws -> String? {
let url = additionalFooterContentUrl
guard url.exists else {
return nil
}
return try wrap(.failedToOpenFile(url.path)) {
try String(contentsOf: url)
}
}
}
extension SiteElement {
func printContents() {
print(path)
elements.forEach { $0.printContents() }
}
}

View File

@ -0,0 +1,35 @@
import Foundation
enum ThumbnailStyle: String, CaseIterable {
case large
case square
case small
var height: Int {
switch self {
case .large:
return 210
case .square:
return 178
case .small:
return 78
}
}
var width: Int {
switch self {
case .large:
return 374
case .square:
return height
case .small:
return height
}
}
}
extension ThumbnailStyle: Codable {
}