2022-08-26 17:40:51 +02:00
|
|
|
import Foundation
|
|
|
|
|
|
|
|
struct Element {
|
|
|
|
|
|
|
|
static let overviewItemCountDefault = 6
|
|
|
|
|
2022-08-30 20:09:12 +02:00
|
|
|
/**
|
|
|
|
The default unique id for the root element
|
|
|
|
*/
|
|
|
|
static let defaultRootId = "root"
|
|
|
|
|
|
|
|
/**
|
|
|
|
The unique id of the element.
|
|
|
|
|
|
|
|
The id is used for short-hand links to pages, in the form of `![page](page_id)`
|
|
|
|
for thumbnail previews or `[text](page:page_id)` for simple links.
|
|
|
|
- Note: The default id for the root element is specified by ``defaultRootId``
|
|
|
|
|
|
|
|
The id can be manually specified using ``GenericMetadata.id``,
|
|
|
|
otherwise it is set to the name of the element folder.
|
|
|
|
*/
|
|
|
|
let id: String
|
|
|
|
|
2022-08-26 17:40:51 +02:00
|
|
|
/**
|
|
|
|
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 (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?
|
|
|
|
|
|
|
|
/**
|
2023-05-31 23:08:55 +02:00
|
|
|
All files which may occur in content but are stored externally.
|
2022-08-26 17:40:51 +02:00
|
|
|
|
|
|
|
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>
|
|
|
|
|
2022-09-08 13:01:32 +02:00
|
|
|
/**
|
|
|
|
Additional images required by the element.
|
|
|
|
|
|
|
|
These images are specified as: `source_name destination_name width (height)`.
|
|
|
|
*/
|
|
|
|
let images: [ManualImage]
|
|
|
|
|
2022-09-29 21:23:41 +02:00
|
|
|
/**
|
|
|
|
The path to the thumbnail file.
|
|
|
|
|
|
|
|
This property is optional, and defaults to ``GenericMetadata.defaultThumbnailName``.
|
|
|
|
Note: The generator first looks for localized versions of the thumbnail by appending `-[lang]` to the file name,
|
|
|
|
e.g. `customThumb-en.jpg`. If no file is found, then the specified file is tried.
|
|
|
|
*/
|
|
|
|
let thumbnailPath: String
|
|
|
|
|
2022-08-26 17:40:51 +02:00
|
|
|
/**
|
|
|
|
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
|
|
|
|
|
|
|
|
/**
|
2022-09-04 20:36:43 +02:00
|
|
|
Indicate the header type to be generated automatically.
|
2022-08-26 17:40:51 +02:00
|
|
|
|
2022-09-04 20:36:43 +02:00
|
|
|
If this option is set to `none`, then custom header code should be present in the page source files
|
|
|
|
- Note: If not specified, this property defaults to `left`.
|
|
|
|
- Note: Overview pages are always using `center`.
|
2022-08-26 17:40:51 +02:00
|
|
|
*/
|
2022-09-04 20:36:43 +02:00
|
|
|
let headerType: HeaderType
|
2022-08-26 17:40:51 +02:00
|
|
|
|
2022-12-01 15:39:39 +01:00
|
|
|
/**
|
|
|
|
Indicate that the overview section should contain a `Newest Content` section before the other sections.
|
|
|
|
- Note: If not specified, this property defaults to `false`
|
|
|
|
*/
|
|
|
|
let showMostRecentSection: Bool
|
|
|
|
|
2022-12-07 01:01:13 +01:00
|
|
|
/**
|
|
|
|
Indicate that the overview section should contain a `Featured Content` section before the other sections.
|
|
|
|
The elements are the page ids of the elements contained in the feature.
|
|
|
|
- Note: If not specified, this property defaults to `false`
|
|
|
|
*/
|
|
|
|
let featuredPages: [String]
|
|
|
|
|
2022-08-26 17:40:51 +02:00
|
|
|
/**
|
|
|
|
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.
|
|
|
|
*/
|
2022-12-01 14:50:26 +01:00
|
|
|
init?(atRoot folder: URL, log: MetadataInfoLogger) {
|
2022-08-26 17:40:51 +02:00
|
|
|
self.inputFolder = folder
|
|
|
|
self.path = ""
|
|
|
|
|
|
|
|
let source = GenericMetadata.metadataFileName
|
2022-12-01 14:50:26 +01:00
|
|
|
guard let metadata = GenericMetadata(source: source, log: log) else {
|
2022-08-26 17:40:51 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-12-01 14:50:26 +01:00
|
|
|
var isValid = true
|
|
|
|
|
2022-08-30 20:09:12 +02:00
|
|
|
self.id = metadata.customId ?? Element.defaultRootId
|
2022-12-01 14:50:26 +01:00
|
|
|
self.author = log.required(metadata.author, name: "author", source: source, &isValid)
|
|
|
|
self.topBarTitle = log.required(metadata.topBarTitle, name: "topBarTitle", source: source, &isValid)
|
|
|
|
self.date = log.castUnused(metadata.date, "date", source: source)
|
|
|
|
self.endDate = log.castUnused(metadata.endDate, "endDate", source: source)
|
|
|
|
self.state = log.cast(metadata.state, "state", source: source)
|
2022-08-26 17:40:51 +02:00
|
|
|
self.sortIndex = log.unused(metadata.sortIndex, "sortIndex", source: source)
|
|
|
|
self.externalFiles = metadata.externalFiles ?? []
|
|
|
|
self.requiredFiles = metadata.requiredFiles ?? [] // Paths are already relative to root
|
2022-12-04 19:15:22 +01:00
|
|
|
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: "", log: log) } ?? []
|
2022-09-29 21:23:41 +02:00
|
|
|
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
|
2022-12-01 14:50:26 +01:00
|
|
|
self.thumbnailStyle = log.castUnused(metadata.thumbnailStyle, "thumbnailStyle", source: source)
|
|
|
|
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source)
|
2022-08-26 17:40:51 +02:00
|
|
|
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
|
2022-12-01 14:50:26 +01:00
|
|
|
self.headerType = log.cast(metadata.headerType, "headerType", source: source)
|
|
|
|
self.showMostRecentSection = metadata.showMostRecentSection ?? false
|
2022-12-07 01:01:13 +01:00
|
|
|
self.featuredPages = metadata.featuredPages ?? []
|
2022-12-01 14:50:26 +01:00
|
|
|
self.languages = log.required(metadata.languages, name: "languages", source: source, &isValid)
|
2022-08-26 17:40:51 +02:00
|
|
|
.compactMap { language in
|
2022-12-01 14:50:26 +01:00
|
|
|
.init(atRoot: folder, data: language, log: log)
|
|
|
|
}
|
2022-08-30 20:09:12 +02:00
|
|
|
// All properties initialized
|
2022-09-04 20:36:43 +02:00
|
|
|
guard !languages.isEmpty else {
|
2022-12-01 14:50:26 +01:00
|
|
|
log.error("No languages found", source: source)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
guard isValid else {
|
2022-09-04 20:36:43 +02:00
|
|
|
return nil
|
|
|
|
}
|
2022-08-30 20:09:12 +02:00
|
|
|
|
2022-12-04 19:15:22 +01:00
|
|
|
//files.add(page: path, id: id)
|
2022-12-01 14:50:26 +01:00
|
|
|
self.readElements(in: folder, source: nil, log: log)
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
2022-12-01 14:50:26 +01:00
|
|
|
mutating func readElements(in folder: URL, source: String?, log: MetadataInfoLogger) {
|
2022-08-26 17:40:51 +02:00
|
|
|
let subFolders: [URL]
|
|
|
|
do {
|
|
|
|
subFolders = try FileManager.default
|
|
|
|
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
|
|
|
|
.filter { $0.isDirectory }
|
|
|
|
} catch {
|
2022-12-01 14:50:26 +01:00
|
|
|
log.error("Failed to read subfolders: \(error)", source: source ?? "root")
|
2022-08-26 17:40:51 +02:00
|
|
|
return
|
|
|
|
}
|
2022-09-08 09:30:54 +02:00
|
|
|
self.elements = subFolders.compactMap { subFolder in
|
2022-08-26 17:40:51 +02:00
|
|
|
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent
|
2022-12-01 14:50:26 +01:00
|
|
|
return Element(parent: self, folder: subFolder, path: s, log: log)
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-01 14:50:26 +01:00
|
|
|
init?(parent: Element, folder: URL, path: String, log: MetadataInfoLogger) {
|
2022-08-26 17:40:51 +02:00
|
|
|
self.inputFolder = folder
|
|
|
|
self.path = path
|
|
|
|
|
|
|
|
let source = path + "/" + GenericMetadata.metadataFileName
|
2022-12-01 14:50:26 +01:00
|
|
|
guard let metadata = GenericMetadata(source: source, log: log) else {
|
2022-08-26 17:40:51 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2022-12-01 14:50:26 +01:00
|
|
|
var isValid = true
|
|
|
|
|
2022-08-30 20:09:12 +02:00
|
|
|
self.id = metadata.customId ?? folder.lastPathComponent
|
2022-08-26 17:40:51 +02:00
|
|
|
self.author = metadata.author ?? parent.author
|
2022-12-01 14:50:26 +01:00
|
|
|
self.topBarTitle = log.unused(metadata.topBarTitle, "topBarTitle", source: source)
|
|
|
|
self.date = metadata.date.unwrapped { log.cast($0, "date", source: source) }
|
2022-12-04 19:15:22 +01:00
|
|
|
self.endDate = metadata.endDate.unwrapped { log.cast($0, "endDate", source: source) }
|
2022-12-01 14:50:26 +01:00
|
|
|
self.state = log.cast(metadata.state, "state", source: source)
|
|
|
|
self.sortIndex = metadata.sortIndex
|
2022-08-26 17:40:51 +02:00
|
|
|
// TODO: Propagate external files from the parent if subpath matches?
|
2022-08-29 13:33:48 +02:00
|
|
|
self.externalFiles = Element.rootPaths(for: metadata.externalFiles, path: path)
|
|
|
|
self.requiredFiles = Element.rootPaths(for: metadata.requiredFiles, path: path)
|
2022-12-04 19:15:22 +01:00
|
|
|
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: path, log: log) } ?? []
|
2022-09-29 21:23:41 +02:00
|
|
|
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
|
2022-12-01 14:50:26 +01:00
|
|
|
self.thumbnailStyle = log.cast(metadata.thumbnailStyle, "thumbnailStyle", source: source)
|
2022-08-26 17:40:51 +02:00
|
|
|
self.useManualSorting = metadata.useManualSorting ?? false
|
|
|
|
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
|
2022-12-01 14:50:26 +01:00
|
|
|
self.headerType = log.cast(metadata.headerType, "headerType", source: source)
|
|
|
|
self.showMostRecentSection = metadata.showMostRecentSection ?? false
|
2022-12-07 01:01:13 +01:00
|
|
|
self.featuredPages = metadata.featuredPages ?? []
|
2022-08-26 17:40:51 +02:00
|
|
|
self.languages = parent.languages.compactMap { parentData in
|
|
|
|
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
|
2022-12-01 14:50:26 +01:00
|
|
|
log.warning("Language '\(parentData.language)' not found", source: source)
|
2022-08-26 17:40:51 +02:00
|
|
|
return nil
|
|
|
|
}
|
2022-12-01 14:50:26 +01:00
|
|
|
return .init(folder: folder, data: data, source: source, parent: parentData, log: log)
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
// Check that each 'language' tag is present, and that all languages appear in the parent
|
2022-12-01 14:50:26 +01:00
|
|
|
log.required(metadata.languages, name: "languages", source: source, &isValid)
|
|
|
|
.compactMap { log.required($0.language, name: "language", source: source, &isValid) }
|
2022-08-26 17:40:51 +02:00
|
|
|
.filter { language in
|
|
|
|
!parent.languages.contains { $0.language == language }
|
|
|
|
}
|
|
|
|
.forEach {
|
2022-12-01 14:50:26 +01:00
|
|
|
log.warning("Language '\($0)' not found in parent, so not generated", source: source)
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
2022-08-30 20:09:12 +02:00
|
|
|
|
|
|
|
// All properties initialized
|
|
|
|
|
2022-12-01 14:50:26 +01:00
|
|
|
if self.date == nil, !parent.useManualSorting {
|
|
|
|
log.error("No 'date', but parent defines 'useManualSorting' = false", source: source)
|
|
|
|
}
|
|
|
|
if date == nil {
|
|
|
|
log.unused(self.endDate, "endDate", source: source)
|
|
|
|
}
|
|
|
|
if self.sortIndex == nil, state != .hidden, parent.useManualSorting {
|
|
|
|
log.error("No 'sortIndex', but parent defines 'useManualSorting' = true", source: source)
|
|
|
|
}
|
|
|
|
|
|
|
|
guard isValid else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
self.readElements(in: folder, source: path, log: log)
|
2022-12-07 01:01:13 +01:00
|
|
|
|
|
|
|
if showMostRecentSection {
|
|
|
|
if elements.isEmpty {
|
|
|
|
log.error("Page has no children", source: source)
|
|
|
|
}
|
|
|
|
languages.filter { $0.mostRecentTitle == nil }.forEach {
|
|
|
|
log.error("'showMostRecentSection' = true, but 'mostRecentTitle' not set for language '\($0.language)'", source: source)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if !featuredPages.isEmpty {
|
|
|
|
if elements.isEmpty {
|
|
|
|
log.error("'featuredPages' contains elements, but page has no children", source: source)
|
|
|
|
}
|
|
|
|
languages.filter { $0.featuredTitle == nil }.forEach {
|
|
|
|
log.error("'featuredPages' contains elements, but 'featuredTitle' not set for language '\($0.language)'", source: source)
|
|
|
|
}
|
|
|
|
}
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
2022-12-04 19:15:22 +01:00
|
|
|
|
2022-12-05 17:25:07 +01:00
|
|
|
func getExternalPageMap(language: String) -> [String : String] {
|
|
|
|
var result = [String : String]()
|
|
|
|
if let ext = getExternalLink(for: language) {
|
|
|
|
result[id] = ext
|
|
|
|
} else {
|
|
|
|
result[id] = path + Element.htmlPagePathAddition(for: language)
|
|
|
|
}
|
|
|
|
elements.forEach { element in
|
|
|
|
element.getExternalPageMap(language: language).forEach { key, value in
|
|
|
|
result[key] = value
|
2022-12-04 19:15:22 +01:00
|
|
|
}
|
|
|
|
}
|
2022-12-05 17:25:07 +01:00
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
|
|
|
private func getExternalLink(for language: String) -> String? {
|
|
|
|
languages.first { $0.language == language }?.externalUrl
|
2022-12-04 19:15:22 +01:00
|
|
|
}
|
2022-12-07 01:01:13 +01:00
|
|
|
|
|
|
|
var needsFirstSection: Bool {
|
|
|
|
showMostRecentSection || !featuredPages.isEmpty
|
|
|
|
}
|
2023-02-22 14:46:40 +01:00
|
|
|
|
|
|
|
var hasVisibleChildren: Bool {
|
|
|
|
!elements.filter { $0.state == .standard }.isEmpty
|
|
|
|
}
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: Paths
|
|
|
|
|
|
|
|
extension Element {
|
|
|
|
|
2022-08-31 00:02:42 +02:00
|
|
|
/**
|
|
|
|
The localized html file name for a language, including a leading slash.
|
|
|
|
*/
|
|
|
|
static func htmlPagePathAddition(for language: String) -> String {
|
|
|
|
"/" + htmlPageName(for: language)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
The localized html file name for a language, without the leading slash.
|
|
|
|
*/
|
|
|
|
static func htmlPageName(for language: String) -> String {
|
|
|
|
"\(language).html"
|
|
|
|
}
|
|
|
|
|
2022-08-26 17:40:51 +02:00
|
|
|
var containsElements: Bool {
|
|
|
|
!elements.isEmpty
|
|
|
|
}
|
|
|
|
|
|
|
|
var hasNestingElements: Bool {
|
2023-02-28 21:13:36 +01:00
|
|
|
elements.contains { $0.hasVisibleChildren }
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
2022-08-26 22:29:32 +02:00
|
|
|
func itemsForOverview(_ count: Int? = nil) -> [Element] {
|
|
|
|
if let shownItemCount = count {
|
|
|
|
return Array(sortedItems.prefix(shownItemCount))
|
|
|
|
} else {
|
|
|
|
return sortedItems
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-01 15:39:39 +01:00
|
|
|
func mostRecentElements(_ count: Int) -> [Element] {
|
2022-12-17 08:26:12 +01:00
|
|
|
guard self.thumbnailStyle == .large else {
|
|
|
|
return []
|
|
|
|
}
|
2022-12-01 15:39:39 +01:00
|
|
|
guard self.containsElements else {
|
|
|
|
return [self]
|
|
|
|
}
|
|
|
|
let all = shownItems
|
|
|
|
.reduce(into: [Element]()) { $0 += $1.mostRecentElements(count) }
|
2023-01-08 21:14:00 +01:00
|
|
|
.filter { $0.thumbnailStyle == .large && $0.state == .standard && $0.date != nil }
|
2022-12-01 15:39:39 +01:00
|
|
|
.sorted { $0.date! > $1.date! }
|
|
|
|
return Array(all.prefix(count))
|
|
|
|
}
|
|
|
|
|
2022-08-28 11:15:36 +02:00
|
|
|
var sortedItems: [Element] {
|
2022-08-26 17:40:51 +02:00
|
|
|
if useManualSorting {
|
2022-08-26 22:29:32 +02:00
|
|
|
return shownItems.sorted { $0.sortIndex! < $1.sortIndex! }
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
2022-08-26 22:29:32 +02:00
|
|
|
return shownItems.sorted { $0.date! > $1.date! }
|
|
|
|
}
|
|
|
|
|
|
|
|
private var shownItems: [Element] {
|
|
|
|
elements.filter { $0.state.isShownInOverview }
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
2022-09-25 22:07:34 +02:00
|
|
|
var linkedElements: [LinkedElement] {
|
2022-09-29 16:23:58 +02:00
|
|
|
let items = sortedItems.filter { $0.state == .standard }
|
2022-09-26 17:00:25 +02:00
|
|
|
let connected = items.enumerated().map { i, element in
|
2022-09-25 22:07:34 +02:00
|
|
|
let previous = i+1 < items.count ? items[i+1] : nil
|
|
|
|
let next = i > 0 ? items[i-1] : nil
|
|
|
|
return (previous, element, next)
|
|
|
|
}
|
2022-09-29 16:23:58 +02:00
|
|
|
return connected + elements.filter { $0.state != .standard }.map { (nil, $0, nil )}
|
2022-09-25 22:07:34 +02:00
|
|
|
}
|
|
|
|
|
2022-08-26 17:40:51 +02:00
|
|
|
/**
|
|
|
|
The url of the top-level section of the element.
|
|
|
|
*/
|
|
|
|
func sectionUrl(for language: String) -> String {
|
2022-08-31 00:02:42 +02:00
|
|
|
path.components(separatedBy: "/").first! + Element.htmlPagePathAddition(for: language)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2022-09-04 17:47:35 +02:00
|
|
|
Create a relative link to another file in the tree.
|
|
|
|
- Parameter file: The full path of the target file, including localization
|
|
|
|
- Returns: The relative url from a localized page of the element to the target file.
|
2022-08-31 00:02:42 +02:00
|
|
|
*/
|
2022-09-04 17:47:35 +02:00
|
|
|
func relativePathToOtherSiteElement(file: String) -> String {
|
2022-12-05 17:25:07 +01:00
|
|
|
guard !file.hasPrefix("/") else {
|
|
|
|
return file
|
|
|
|
}
|
2022-08-31 00:02:42 +02:00
|
|
|
// Note: The element `path` is missing the last component
|
|
|
|
// i.e. travel/alps instead of travel/alps/en.html
|
|
|
|
let ownParts = path.components(separatedBy: "/")
|
2022-09-04 17:47:35 +02:00
|
|
|
let pageParts = file.components(separatedBy: "/")
|
2022-08-31 00:02:42 +02:00
|
|
|
|
|
|
|
// Find the common elements of the path, which can be discarded
|
|
|
|
var index = 0
|
2023-02-22 11:47:26 +01:00
|
|
|
while index < pageParts.count && index < ownParts.count && pageParts[index] == ownParts[index] {
|
2022-08-31 00:02:42 +02:00
|
|
|
index += 1
|
|
|
|
}
|
|
|
|
// The relative path needs to go down to the first common folder,
|
|
|
|
// before going up to the target page
|
|
|
|
let allParts = [String](repeating: "..", count: ownParts.count-index)
|
2022-12-01 15:39:39 +01:00
|
|
|
+ pageParts.dropFirst(index)
|
2022-08-31 00:02:42 +02:00
|
|
|
return allParts.joined(separator: "/")
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
2022-09-04 17:47:13 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
The relative path to the site root.
|
|
|
|
*/
|
|
|
|
var pathToRoot: String? {
|
|
|
|
guard path != "" else {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
let downPathCount = path.components(separatedBy: "/").count
|
|
|
|
return [String](repeating: "..", count: downPathCount).joined(separator: "/")
|
|
|
|
}
|
2022-08-26 17:40:51 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
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 {
|
2022-08-29 13:33:48 +02:00
|
|
|
Element.relativeToRoot(filePath: filePath, folder: path)
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
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 nonAbsolutePathRelativeToRootForContainedInputFile(_ filePath: String) -> String? {
|
|
|
|
Element.containedFileRelativeToRoot(filePath: filePath, folder: path)
|
|
|
|
}
|
|
|
|
|
|
|
|
static func relativeToRoot(filePath: String, folder path: String) -> String {
|
|
|
|
containedFileRelativeToRoot(filePath: filePath, folder: path) ?? filePath
|
|
|
|
}
|
|
|
|
|
|
|
|
static func containedFileRelativeToRoot(filePath: String, folder path: String) -> String? {
|
2022-09-08 13:01:32 +02:00
|
|
|
if path == "" {
|
|
|
|
return filePath
|
|
|
|
}
|
2022-08-29 13:33:48 +02:00
|
|
|
if filePath.hasPrefix("/") || filePath.hasPrefix("http") || filePath.hasPrefix("mailto:") {
|
|
|
|
return nil
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
return "\(path)/\(filePath)"
|
|
|
|
}
|
|
|
|
|
2022-09-08 13:01:32 +02:00
|
|
|
/**
|
|
|
|
Convert a set of relative paths to paths that are relative to the root element.
|
|
|
|
- Parameter input: The set of paths to convert.
|
|
|
|
- Parameter path: The path to the folder where the paths are currently relative to.
|
|
|
|
*/
|
2022-08-29 13:33:48 +02:00
|
|
|
static func rootPaths(for input: Set<String>?, path: String) -> Set<String> {
|
2022-09-05 12:59:32 +02:00
|
|
|
guard let input = input else {
|
|
|
|
return []
|
|
|
|
}
|
|
|
|
return Set(input.map { relativeToRoot(filePath: $0, folder: path) })
|
2022-08-29 13:33:48 +02:00
|
|
|
}
|
|
|
|
|
2022-08-26 17:40:51 +02:00
|
|
|
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 {
|
|
|
|
|
2022-09-25 17:19:07 +02:00
|
|
|
/**
|
|
|
|
The full url (relative to root) for the localized page
|
|
|
|
- Parameter language: The language of the page where the url should point
|
|
|
|
*/
|
2022-08-26 17:40:51 +02:00
|
|
|
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 {
|
2022-08-31 00:02:42 +02:00
|
|
|
guard path != "" else {
|
|
|
|
return Element.htmlPageName(for: language)
|
|
|
|
}
|
|
|
|
return path + Element.htmlPagePathAddition(for: language)
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
Get the next language to switch to with the language button.
|
|
|
|
*/
|
2022-08-26 22:29:32 +02:00
|
|
|
func nextLanguage(for language: String) -> String? {
|
2022-08-26 17:40:51 +02:00
|
|
|
let langs = languages.map { $0.language }
|
2022-08-26 22:29:32 +02:00
|
|
|
guard let index = langs.firstIndex(of: language) else {
|
2022-08-26 17:40:51 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
for i in 1..<langs.count {
|
|
|
|
let next = langs[(index + i) % langs.count]
|
|
|
|
guard hasContent(for: next) else {
|
|
|
|
continue
|
|
|
|
}
|
2022-08-26 22:29:32 +02:00
|
|
|
guard next != language else {
|
2022-08-26 17:40:51 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return next
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func linkPreviewImage(for language: String) -> String? {
|
2022-09-29 21:23:41 +02:00
|
|
|
localized(for: language).linkPreviewImage ?? thumbnailFileName(for: language)
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 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)
|
2022-08-31 08:46:23 +02:00
|
|
|
guard url.exists, let size = url.size, size > 0 else {
|
2022-08-26 17:40:51 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return url
|
|
|
|
}
|
|
|
|
|
|
|
|
private func hasContent(for language: String) -> Bool {
|
2022-08-26 22:29:32 +02:00
|
|
|
if !elements.isEmpty {
|
|
|
|
return true
|
|
|
|
}
|
|
|
|
return existingContentUrl(for: language) != nil
|
2022-08-26 17:40:51 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: Header and Footer
|
|
|
|
|
|
|
|
extension Element {
|
|
|
|
|
2022-12-04 19:15:22 +01:00
|
|
|
var additionalHeadContentPath: String {
|
2022-08-26 17:40:51 +02:00
|
|
|
path + "/head.html"
|
|
|
|
}
|
|
|
|
|
2022-12-04 19:15:22 +01:00
|
|
|
var additionalFooterContentPath: String {
|
2022-08-26 17:40:51 +02:00
|
|
|
path + "/footer.html"
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// MARK: Debug
|
|
|
|
|
|
|
|
extension Element {
|
|
|
|
|
|
|
|
func printTree(indentation: String = "") {
|
|
|
|
print(indentation + "/" + path)
|
|
|
|
elements.forEach { $0.printTree(indentation: indentation + " ") }
|
|
|
|
}
|
|
|
|
}
|
2022-09-08 13:01:32 +02:00
|
|
|
|
|
|
|
// MARK: Images
|
|
|
|
|
|
|
|
extension Element {
|
|
|
|
|
|
|
|
struct ManualImage {
|
|
|
|
|
|
|
|
let sourcePath: String
|
|
|
|
|
|
|
|
let destinationPath: String
|
|
|
|
|
|
|
|
let desiredWidth: Int
|
|
|
|
|
|
|
|
let desiredHeight: Int?
|
|
|
|
|
2022-12-04 19:15:22 +01:00
|
|
|
init?(input: String, path: String, log: MetadataInfoLogger) {
|
2022-09-08 13:01:32 +02:00
|
|
|
let parts = input.components(separatedBy: " ").filter { !$0.isEmpty }
|
|
|
|
guard parts.count == 3 || parts.count == 4 else {
|
2022-12-04 19:15:22 +01:00
|
|
|
log.error("Invalid image specification, expected 'source dest width (height)", source: path)
|
2022-09-08 13:01:32 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
guard let width = Int(parts[2]) else {
|
2022-12-04 19:15:22 +01:00
|
|
|
log.error("Invalid width for image \(parts[0])", source: path)
|
2022-09-08 13:01:32 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
self.sourcePath = Element.relativeToRoot(filePath: parts[0], folder: path)
|
|
|
|
self.destinationPath = Element.relativeToRoot(filePath: parts[1], folder: path)
|
|
|
|
self.desiredWidth = width
|
|
|
|
guard parts.count == 4 else {
|
|
|
|
self.desiredHeight = nil
|
|
|
|
return
|
|
|
|
}
|
|
|
|
guard let height = Int(parts[3]) else {
|
2022-12-04 19:15:22 +01:00
|
|
|
log.error("Invalid height for image \(parts[0])", source: path)
|
2022-09-08 13:01:32 +02:00
|
|
|
return nil
|
|
|
|
}
|
|
|
|
self.desiredHeight = height
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2022-09-23 09:22:00 +02:00
|
|
|
|
|
|
|
extension Element {
|
|
|
|
|
|
|
|
/**
|
|
|
|
Find a page by its page ID within the tree of the element.
|
|
|
|
*/
|
|
|
|
func find(_ pageId: String) -> Element? {
|
|
|
|
if self.id == pageId {
|
|
|
|
return self
|
|
|
|
}
|
|
|
|
for child in elements {
|
|
|
|
if let found = child.find(pageId) {
|
|
|
|
return found
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
2022-09-25 17:19:07 +02:00
|
|
|
|
|
|
|
var pathComponents: [String] {
|
|
|
|
path.components(separatedBy: "/")
|
|
|
|
}
|
|
|
|
|
|
|
|
var lastPathComponent: String {
|
|
|
|
pathComponents.last!
|
|
|
|
}
|
|
|
|
|
|
|
|
func find(elementWithFolder folder: String) -> Element? {
|
|
|
|
elements.first { $0.lastPathComponent == folder }
|
|
|
|
}
|
|
|
|
|
|
|
|
func makePath(language: String, from root: Element) -> [String] {
|
|
|
|
let parts = pathComponents.dropLast()
|
|
|
|
var result = [String]()
|
|
|
|
var node = root
|
|
|
|
for part in parts {
|
|
|
|
guard let child = node.find(elementWithFolder: part) else {
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
result.append(child.title(for: language))
|
|
|
|
node = child
|
|
|
|
}
|
|
|
|
return result
|
|
|
|
}
|
|
|
|
|
|
|
|
func findParent(from root: Element) -> Element? {
|
|
|
|
var node = root
|
|
|
|
for part in pathComponents.dropLast() {
|
|
|
|
guard let child = node.find(elementWithFolder: part) else {
|
|
|
|
return node
|
|
|
|
}
|
|
|
|
node = child
|
|
|
|
}
|
|
|
|
return node
|
|
|
|
}
|
2022-09-23 09:22:00 +02:00
|
|
|
}
|
2022-09-29 21:23:41 +02:00
|
|
|
|
|
|
|
// MARK: Thumbnails
|
|
|
|
|
|
|
|
extension Element {
|
|
|
|
|
|
|
|
static let defaultThumbnailName = "thumbnail.jpg"
|
|
|
|
|
|
|
|
/**
|
|
|
|
Find the thumbnail for the element.
|
|
|
|
|
|
|
|
This function uses either the custom thumbnail path from the metadata or the default name
|
|
|
|
to find a thumbnail. It first checks if a localized version of the thumbnail exists, or returns the
|
|
|
|
generic version. If no thumbnail image could be found on disk, then an error is logged and the
|
|
|
|
generic path is returned.
|
|
|
|
|
|
|
|
- Parameter language: The language of the thumbnail
|
|
|
|
- Returns: The thumbnail (either the localized or the generic version)
|
|
|
|
*/
|
|
|
|
func thumbnailFilePath(for language: String) -> (source: String, destination: String) {
|
|
|
|
let localizedThumbnail = thumbnailPath.insert("-\(language)", beforeLast: ".")
|
|
|
|
let localizedThumbnailUrl = inputFolder.appendingPathComponent(localizedThumbnail)
|
|
|
|
|
|
|
|
if localizedThumbnailUrl.exists {
|
|
|
|
let source = pathRelativeToRootForContainedInputFile(localizedThumbnail)
|
|
|
|
let ext = thumbnailPath.lastComponentAfter(".")
|
|
|
|
let destination = pathRelativeToRootForContainedInputFile("thumbnail-\(language).\(ext)")
|
|
|
|
return (source, destination)
|
|
|
|
}
|
|
|
|
let source = pathRelativeToRootForContainedInputFile(thumbnailPath)
|
|
|
|
let ext = thumbnailPath.lastComponentAfter(".")
|
|
|
|
let destination = pathRelativeToRootForContainedInputFile("thumbnail.\(ext)")
|
|
|
|
return (source, destination)
|
|
|
|
}
|
|
|
|
|
|
|
|
private func thumbnailFileName(for language: String) -> String? {
|
|
|
|
let localizedThumbnailName = thumbnailPath.insert("-\(language)", beforeLast: ".")
|
|
|
|
let localizedThumbnail = pathRelativeToRootForContainedInputFile(localizedThumbnailName)
|
|
|
|
let localizedThumbnailUrl = inputFolder.appendingPathComponent(localizedThumbnail)
|
|
|
|
|
|
|
|
if localizedThumbnailUrl.exists {
|
|
|
|
return localizedThumbnailName
|
|
|
|
}
|
|
|
|
|
|
|
|
let thumbnailUrl = inputFolder.appendingPathComponent(thumbnailPath)
|
|
|
|
if !thumbnailUrl.exists {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
return thumbnailPath
|
|
|
|
}
|
|
|
|
}
|