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 /** 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 /** 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 } } var linkedElements: [LinkedElement] { let items = sortedItems.filter { $0.state == .standard } let connected = items.enumerated().map { i, element in let previous = i+1 < items.count ? items[i+1] : nil let next = i > 0 ? items[i-1] : nil return (previous, element, next) } return connected + elements.filter { $0.state != .standard }.map { (nil, $0, nil )} } /** 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?, path: String) -> Set { 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) } /** The full url (relative to root) for the localized page - Parameter language: The language of the page where the url should point */ 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.. 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 } } } 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 } 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 } }