CHGenerator/WebsiteGenerator/Content/Element.swift
2022-09-08 13:01:32 +02:00

597 lines
20 KiB
Swift

import Foundation
struct Element {
static let overviewItemCountDefault = 6
/**
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
/**
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?
/**
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>
/**
Additional images required by the element.
These images are specified as: `source_name destination_name width (height)`.
*/
let images: [ManualImage]
/**
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 the header type to be generated automatically.
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`.
*/
let headerType: HeaderType
/**
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) {
self.inputFolder = folder
self.path = ""
let source = GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else {
return nil
}
self.id = metadata.customId ?? Element.defaultRootId
self.author = log.required(metadata.author, name: "author", source: source) ?? "author"
self.topBarTitle = log
.required(metadata.topBarTitle, name: "topBarTitle", source: source) ?? "My Website"
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.images = metadata.images?.compactMap { ManualImage(input: $0, path: "") } ?? []
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.headerType = log.headerType(metadata.headerType, source: source)
self.languages = log.required(metadata.languages, name: "languages", source: source)?
.compactMap { language in
.init(atRoot: folder, data: language)
} ?? []
// All properties initialized
guard !languages.isEmpty else {
log.add(error: "No languages found", source: source)
return nil
}
files.add(page: path, id: id)
self.readElements(in: folder, source: nil)
}
mutating func readElements(in folder: URL, source: String?) {
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 = subFolders.compactMap { subFolder in
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent
return Element(parent: self, folder: subFolder, path: s)
}
}
init?(parent: Element, folder: URL, path: String) {
self.inputFolder = folder
self.path = path
let source = path + "/" + GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source) else {
return nil
}
self.id = metadata.customId ?? folder.lastPathComponent
self.author = metadata.author ?? parent.author
self.topBarTitle = log
.unused(metadata.topBarTitle, "topBarTitle", source: source) ?? parent.topBarTitle
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)
}
}
let state = log.state(metadata.state, source: source)
self.state = state
self.sortIndex = metadata.sortIndex.ifNil {
if state != .hidden, 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 = Element.rootPaths(for: metadata.externalFiles, path: path)
self.requiredFiles = Element.rootPaths(for: metadata.requiredFiles, path: path)
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: path) } ?? []
self.thumbnailStyle = log.thumbnailStyle(metadata.thumbnailStyle, source: source)
self.useManualSorting = metadata.useManualSorting ?? false
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
self.headerType = log.headerType(metadata.headerType, source: source)
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)
}
// All properties initialized
files.add(page: path, id: id)
self.readElements(in: folder, source: path)
}
}
// MARK: Paths
extension Element {
/**
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"
}
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! + Element.htmlPagePathAddition(for: language)
}
/**
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.
*/
func relativePathToOtherSiteElement(file: String) -> String {
// Note: The element `path` is missing the last component
// i.e. travel/alps instead of travel/alps/en.html
let ownParts = path.components(separatedBy: "/")
let pageParts = file.components(separatedBy: "/")
// Find the common elements of the path, which can be discarded
var index = 0
while pageParts[index] == ownParts[index] {
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)
+ pageParts.dropFirst(index)
return allParts.joined(separator: "/")
}
/**
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: "/")
}
/**
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 {
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? {
if path == "" {
return filePath
}
if filePath.hasPrefix("/") || filePath.hasPrefix("http") || filePath.hasPrefix("mailto:") {
return nil
}
return "\(path)/\(filePath)"
}
/**
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.
*/
static func rootPaths(for input: Set<String>?, path: String) -> Set<String> {
guard let input = input else {
return []
}
return Set(input.map { relativeToRoot(filePath: $0, folder: path) })
}
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 {
guard path != "" else {
return Element.htmlPageName(for: language)
}
return path + Element.htmlPagePathAddition(for: language)
}
/**
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, let size = url.size, size > 0 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 + " ") }
}
}
// MARK: Images
extension Element {
struct ManualImage {
let sourcePath: String
let destinationPath: String
let desiredWidth: Int
let desiredHeight: Int?
init?(input: String, path: String) {
let parts = input.components(separatedBy: " ").filter { !$0.isEmpty }
guard parts.count == 3 || parts.count == 4 else {
log.add(error: "Invalid image specification, expected 'source dest width (height)", source: path)
return nil
}
guard let width = Int(parts[2]) else {
log.add(error: "Invalid width for image \(parts[0])", source: path)
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 {
log.add(error: "Invalid height for image \(parts[0])", source: path)
return nil
}
self.desiredHeight = height
}
}
}