CHGenerator/Sources/Generator/Content/Element.swift

793 lines
27 KiB
Swift
Raw Normal View History

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
/**
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.
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]
/**
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
/**
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-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-09-04 20:36:43 +02:00
let headerType: HeaderType
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
/**
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]
/**
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, log: MetadataInfoLogger) {
self.inputFolder = folder
self.path = ""
let source = GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source, log: log) else {
return nil
}
var isValid = true
2022-08-30 20:09:12 +02:00
self.id = metadata.customId ?? Element.defaultRootId
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)
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) } ?? []
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
self.thumbnailStyle = log.castUnused(metadata.thumbnailStyle, "thumbnailStyle", source: source)
self.useManualSorting = log.unused(metadata.useManualSorting, "useManualSorting", source: source)
self.overviewItemCount = metadata.overviewItemCount ?? Element.overviewItemCountDefault
self.headerType = log.cast(metadata.headerType, "headerType", source: source)
self.showMostRecentSection = metadata.showMostRecentSection ?? false
self.featuredPages = metadata.featuredPages ?? []
self.languages = log.required(metadata.languages, name: "languages", source: source, &isValid)
.compactMap { language in
.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 {
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)
self.readElements(in: folder, source: nil, log: log)
}
mutating func readElements(in folder: URL, source: String?, log: MetadataInfoLogger) {
let subFolders: [URL]
do {
subFolders = try FileManager.default
.contentsOfDirectory(at: folder, includingPropertiesForKeys: [.isDirectoryKey])
.filter { $0.isDirectory }
} catch {
log.error("Failed to read subfolders: \(error)", source: source ?? "root")
return
}
self.elements = subFolders.compactMap { subFolder in
let s = (source.unwrapped { $0 + "/" } ?? "") + subFolder.lastPathComponent
return Element(parent: self, folder: subFolder, path: s, log: log)
}
}
init?(parent: Element, folder: URL, path: String, log: MetadataInfoLogger) {
self.inputFolder = folder
self.path = path
let source = path + "/" + GenericMetadata.metadataFileName
guard let metadata = GenericMetadata(source: source, log: log) else {
return nil
}
var isValid = true
2022-08-30 20:09:12 +02:00
self.id = metadata.customId ?? folder.lastPathComponent
self.author = metadata.author ?? parent.author
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) }
self.state = log.cast(metadata.state, "state", source: source)
self.sortIndex = metadata.sortIndex
// 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)
2022-12-04 19:15:22 +01:00
self.images = metadata.images?.compactMap { ManualImage(input: $0, path: path, log: log) } ?? []
self.thumbnailPath = metadata.thumbnailPath ?? Element.defaultThumbnailName
self.thumbnailStyle = log.cast(metadata.thumbnailStyle, "thumbnailStyle", source: source)
self.useManualSorting = metadata.useManualSorting ?? false
self.overviewItemCount = metadata.overviewItemCount ?? parent.overviewItemCount
self.headerType = log.cast(metadata.headerType, "headerType", source: source)
self.showMostRecentSection = metadata.showMostRecentSection ?? false
self.featuredPages = metadata.featuredPages ?? []
self.languages = parent.languages.compactMap { parentData in
guard let data = metadata.languages?.first(where: { $0.language == parentData.language }) else {
log.warning("Language '\(parentData.language)' not found", source: source)
return nil
}
return .init(folder: folder, data: data, source: source, parent: parentData, log: log)
}
// Check that each 'language' tag is present, and that all languages appear in the parent
log.required(metadata.languages, name: "languages", source: source, &isValid)
.compactMap { log.required($0.language, name: "language", source: source, &isValid) }
.filter { language in
!parent.languages.contains { $0.language == language }
}
.forEach {
log.warning("Language '\($0)' not found in parent, so not generated", source: source)
}
2022-08-30 20:09:12 +02:00
// All properties initialized
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)
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-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
}
var needsFirstSection: Bool {
showMostRecentSection || !featuredPages.isEmpty
}
var hasVisibleChildren: Bool {
!elements.filter { $0.state == .standard }.isEmpty
}
}
// 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"
}
var containsElements: Bool {
!elements.isEmpty
}
var hasNestingElements: Bool {
2023-02-28 21:13:36 +01:00
elements.contains { $0.hasVisibleChildren }
}
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] {
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) }
.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] {
if useManualSorting {
2022-08-26 22:29:32 +02:00
return shownItems.sorted { $0.sortIndex! < $1.sortIndex! }
}
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-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 }
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
}
/**
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-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: "/")
}
/**
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? {
2022-09-08 13:01:32 +02:00
if path == "" {
return filePath
}
if filePath.hasPrefix("/") || filePath.hasPrefix("http") || filePath.hasPrefix("mailto:") {
return nil
}
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.
*/
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) })
}
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
*/
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)
}
/**
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? {
let langs = languages.map { $0.language }
2022-08-26 22:29:32 +02:00
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
}
2022-08-26 22:29:32 +02:00
guard next != language else {
return nil
}
return next
}
return nil
}
func linkPreviewImage(for language: String) -> String? {
localized(for: language).linkPreviewImage ?? thumbnailFileName(for: language)
}
}
// 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 {
2022-08-26 22:29:32 +02:00
if !elements.isEmpty {
return true
}
return existingContentUrl(for: language) != nil
}
}
// MARK: Header and Footer
extension Element {
2022-12-04 19:15:22 +01:00
var additionalHeadContentPath: String {
path + "/head.html"
}
2022-12-04 19:15:22 +01:00
var additionalFooterContentPath: String {
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
}
// 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
}
}