446 lines
15 KiB
Swift
446 lines
15 KiB
Swift
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 }
|
|
}
|
|
|
|
func itemsForOverview(_ count: Int? = nil) -> [Element] {
|
|
if let shownItemCount = count {
|
|
return Array(sortedItems.prefix(shownItemCount))
|
|
} else {
|
|
return sortedItems
|
|
}
|
|
}
|
|
|
|
var sortedItems: [Element] {
|
|
if useManualSorting {
|
|
return shownItems.sorted { $0.sortIndex! < $1.sortIndex! }
|
|
}
|
|
return shownItems.sorted { $0.date! > $1.date! }
|
|
}
|
|
|
|
private var shownItems: [Element] {
|
|
elements.filter { $0.state.isShownInOverview }
|
|
}
|
|
|
|
/**
|
|
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 {
|
|
log.add(error: "Missing thumbnail", source: path)
|
|
return Element.defaultThumbnailName
|
|
}
|
|
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 language: String) -> String? {
|
|
let langs = languages.map { $0.language }
|
|
guard let index = langs.firstIndex(of: language) else {
|
|
return nil
|
|
}
|
|
for i in 1..<langs.count {
|
|
let next = langs[(index + i) % langs.count]
|
|
guard hasContent(for: next) else {
|
|
continue
|
|
}
|
|
guard next != language 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 {
|
|
if !elements.isEmpty {
|
|
return true
|
|
}
|
|
return 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 + " ") }
|
|
}
|
|
}
|