Convert Xcode project to swift package
This commit is contained in:
229
Sources/Generator/Content/Element+LocalizedMetadata.swift
Normal file
229
Sources/Generator/Content/Element+LocalizedMetadata.swift
Normal file
@ -0,0 +1,229 @@
|
||||
import Foundation
|
||||
|
||||
extension Element {
|
||||
|
||||
/**
|
||||
Metadata localized for a specific language.
|
||||
*/
|
||||
struct LocalizedMetadata {
|
||||
|
||||
static let moreLinkDefaultText = "DefaultMoreText"
|
||||
|
||||
/**
|
||||
The language for which the content is specified.
|
||||
- Note: This field is mandatory
|
||||
*/
|
||||
let language: String
|
||||
|
||||
/**
|
||||
- Note: This field is mandatory
|
||||
The title used in the page header.
|
||||
*/
|
||||
let title: String
|
||||
|
||||
/**
|
||||
The subtitle used in the page header.
|
||||
*/
|
||||
let subtitle: String?
|
||||
|
||||
/**
|
||||
The description text used in the page header.
|
||||
*/
|
||||
let description: String?
|
||||
|
||||
/**
|
||||
The title to use for the link preview.
|
||||
|
||||
If `nil` is specified, then the localized element `title` is used.
|
||||
*/
|
||||
let linkPreviewTitle: String
|
||||
|
||||
/**
|
||||
The file name of the link preview image.
|
||||
- Note: The image must be located in the element folder.
|
||||
- Note: If `nil` is specified, then the (localized) thumbnail is used, if available.
|
||||
*/
|
||||
let linkPreviewImage: String?
|
||||
|
||||
/**
|
||||
The description text for the link preview.
|
||||
- Note: If `nil` is specified, then first the (localized) element `subtitle` is used.
|
||||
If this is `nil` too, then the localized `description` of the element is used.
|
||||
*/
|
||||
let linkPreviewDescription: String
|
||||
|
||||
/**
|
||||
The text on the link to show the section page when previewing multiple sections on an overview page.
|
||||
- Note: If this value is inherited from the parent, if it is not defined. There must be at least one
|
||||
element in the path that defines this property.
|
||||
*/
|
||||
let moreLinkText: String
|
||||
|
||||
/**
|
||||
The text on the back navigation link of **contained** elements.
|
||||
|
||||
This text does not appear on the section page, but on the pages contained within the section.
|
||||
*/
|
||||
let backLinkText: String
|
||||
|
||||
/**
|
||||
The text on the back navigation link of the **parent** element.
|
||||
|
||||
This text appears on the section page, but not on the pages contained within the section.
|
||||
*/
|
||||
let parentBackLinkText: String
|
||||
|
||||
/**
|
||||
The text to show as a title for placeholder boxes
|
||||
|
||||
Placeholders are included in missing pages.
|
||||
- Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property.
|
||||
*/
|
||||
let placeholderTitle: String
|
||||
|
||||
/**
|
||||
The text to show as a description for placeholder boxes
|
||||
|
||||
Placeholders are included in missing pages.
|
||||
- Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property.
|
||||
*/
|
||||
let placeholderText: String
|
||||
|
||||
/**
|
||||
An optional suffix to add to the title on a page.
|
||||
|
||||
This can be useful to express a different author, project grouping, etc.
|
||||
*/
|
||||
let titleSuffix: String?
|
||||
|
||||
/**
|
||||
An optional suffix to add to the thumbnail title of a page.
|
||||
|
||||
This can be useful to express a different author, project grouping, etc.
|
||||
*/
|
||||
let thumbnailSuffix: String?
|
||||
|
||||
/**
|
||||
A text to place in the top right corner of a large thumbnail.
|
||||
|
||||
The text should be a very short string to fit into the corner, like `soon`, or `draft`
|
||||
|
||||
- Note: This property is ignored if `thumbnailStyle` is not `large`.
|
||||
*/
|
||||
let cornerText: String?
|
||||
|
||||
/**
|
||||
The external url to use instead of automatically generating the page.
|
||||
|
||||
This property can be used for links to other parts of the site, like additional services.
|
||||
It can also be set to manually write a page.
|
||||
*/
|
||||
let externalUrl: String?
|
||||
}
|
||||
}
|
||||
|
||||
extension Element.LocalizedMetadata {
|
||||
|
||||
init?(atRoot folder: URL, data: GenericMetadata.LocalizedMetadata) {
|
||||
// Go through all elements and check them for completeness
|
||||
// In the end, check that all required elements are present
|
||||
var isComplete = true
|
||||
func markAsIncomplete() {
|
||||
isComplete = false
|
||||
}
|
||||
let source = "root"
|
||||
self.language = log
|
||||
.required(data.language, name: "language", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.title = log
|
||||
.required(data.title, name: "title", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.subtitle = data.subtitle
|
||||
self.description = data.description
|
||||
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
|
||||
self.linkPreviewImage = log
|
||||
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
|
||||
let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle
|
||||
self.linkPreviewDescription = log
|
||||
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.moreLinkText = data.moreLinkText ?? Element.LocalizedMetadata.moreLinkDefaultText
|
||||
self.backLinkText = log
|
||||
.required(data.backLinkText, name: "backLinkText", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.parentBackLinkText = "" // Root has no parent
|
||||
self.placeholderTitle = log
|
||||
.required(data.placeholderTitle, name: "placeholderTitle", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.placeholderText = log
|
||||
.required(data.placeholderText, name: "placeholderText", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.titleSuffix = data.titleSuffix
|
||||
self.thumbnailSuffix = log.unused(data.thumbnailSuffix, "thumbnailSuffix", source: source)
|
||||
self.cornerText = log.unused(data.cornerText, "cornerText", source: source)
|
||||
self.externalUrl = log.unexpected(data.externalUrl, name: "externalUrl", source: source)
|
||||
|
||||
guard isComplete else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
init?(folder: URL, data: GenericMetadata.LocalizedMetadata, source: String, parent: Element.LocalizedMetadata) {
|
||||
// Go through all elements and check them for completeness
|
||||
// In the end, check that all required elements are present
|
||||
var isComplete = true
|
||||
func markAsIncomplete() {
|
||||
isComplete = false
|
||||
}
|
||||
self.language = parent.language
|
||||
self.title = log
|
||||
.required(data.title, name: "title", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.subtitle = data.subtitle
|
||||
self.description = data.description
|
||||
self.linkPreviewTitle = data.linkPreviewTitle ?? data.title ?? ""
|
||||
self.linkPreviewImage = log
|
||||
.linkPreviewThumbnail(customFile: data.linkPreviewImage, for: language, in: folder, source: source)
|
||||
let linkPreviewDescription = data.linkPreviewDescription ?? data.description ?? data.subtitle
|
||||
self.linkPreviewDescription = log
|
||||
.required(linkPreviewDescription, name: "linkPreviewDescription", source: source)
|
||||
.ifNil(markAsIncomplete) ?? ""
|
||||
self.moreLinkText = log.moreLinkText(data.moreLinkText, parent: parent.moreLinkText, source: source)
|
||||
self.backLinkText = data.backLinkText ?? data.title ?? ""
|
||||
self.parentBackLinkText = parent.backLinkText
|
||||
self.placeholderTitle = data.placeholderTitle ?? parent.placeholderTitle
|
||||
self.placeholderText = data.placeholderText ?? parent.placeholderText
|
||||
self.titleSuffix = data.titleSuffix
|
||||
self.thumbnailSuffix = data.thumbnailSuffix
|
||||
self.cornerText = data.cornerText
|
||||
self.externalUrl = data.externalUrl
|
||||
|
||||
guard isComplete else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Thumbnails
|
||||
|
||||
extension Element {
|
||||
|
||||
static let defaultThumbnailName = "thumbnail.jpg"
|
||||
|
||||
static func localizedThumbnailName(for language: String) -> String {
|
||||
"thumbnail-\(language).jpg"
|
||||
}
|
||||
|
||||
static func findThumbnail(for language: String, in folder: URL) -> String? {
|
||||
let localizedThumbnail = localizedThumbnailName(for: language)
|
||||
let localizedThumbnailUrl = folder.appendingPathComponent(localizedThumbnail)
|
||||
if localizedThumbnailUrl.exists {
|
||||
return localizedThumbnail
|
||||
}
|
||||
let defaultThumbnailUrl = folder.appendingPathComponent(defaultThumbnailName)
|
||||
if defaultThumbnailUrl.exists {
|
||||
return defaultThumbnailName
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
596
Sources/Generator/Content/Element.swift
Normal file
596
Sources/Generator/Content/Element.swift
Normal file
@ -0,0 +1,596 @@
|
||||
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 ``
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
207
Sources/Generator/Content/GenericMetadata+Localized.swift
Normal file
207
Sources/Generator/Content/GenericMetadata+Localized.swift
Normal file
@ -0,0 +1,207 @@
|
||||
import Foundation
|
||||
|
||||
extension GenericMetadata {
|
||||
|
||||
/**
|
||||
Metadata localized for a specific language.
|
||||
*/
|
||||
struct LocalizedMetadata {
|
||||
|
||||
/**
|
||||
The language for which the content is specified.
|
||||
- Note: This field is mandatory
|
||||
*/
|
||||
let language: String?
|
||||
|
||||
/**
|
||||
- Note: This field is mandatory
|
||||
The title used in the page header.
|
||||
*/
|
||||
let title: String?
|
||||
|
||||
/**
|
||||
The subtitle used in the page header.
|
||||
*/
|
||||
let subtitle: String?
|
||||
|
||||
/**
|
||||
The description text used in the page header
|
||||
*/
|
||||
let description: String?
|
||||
|
||||
/**
|
||||
The title to use for the link preview.
|
||||
|
||||
If `nil` is specified, then the localized element `title` is used.
|
||||
*/
|
||||
let linkPreviewTitle: String?
|
||||
|
||||
/**
|
||||
The file name of the link preview image.
|
||||
- Note: The image must be located in the element folder.
|
||||
- Note: If `nil` is specified, then the (localized) thumbnail is used.
|
||||
*/
|
||||
let linkPreviewImage: String?
|
||||
|
||||
/**
|
||||
The description text for the link preview.
|
||||
- Note: If `nil` is specified, then first the (localized) element `subtitle` is used.
|
||||
If this is `nil` too, then the localized `description` of the element is used.
|
||||
*/
|
||||
let linkPreviewDescription: String?
|
||||
|
||||
/**
|
||||
The text on the link to show the section page when previewing multiple sections on an overview page.
|
||||
- Note: If this value is inherited from the parent, if it is not defined. There must be at least one
|
||||
element in the path that defines this property.
|
||||
*/
|
||||
let moreLinkText: String?
|
||||
|
||||
/**
|
||||
The text on the back navigation link of **contained** elements.
|
||||
|
||||
This text does not appear on the section page, but on the pages contained within the section.
|
||||
- Note: If this property is not specified, then the root `backLinkText` is used.
|
||||
- Note: The root element must specify this property.
|
||||
*/
|
||||
let backLinkText: String?
|
||||
|
||||
/**
|
||||
The text to show as a title for placeholder boxes
|
||||
|
||||
Placeholders are included in missing pages.
|
||||
- Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property.
|
||||
*/
|
||||
let placeholderTitle: String?
|
||||
|
||||
/**
|
||||
The text to show as a description for placeholder boxes
|
||||
|
||||
Placeholders are included in missing pages.
|
||||
- Note: If no value is specified, then this property is inherited from the parent. The root element must specify this property.
|
||||
*/
|
||||
let placeholderText: String?
|
||||
|
||||
/**
|
||||
An optional suffix to add to the title on a page.
|
||||
|
||||
This can be useful to express a different author, project grouping, etc.
|
||||
*/
|
||||
let titleSuffix: String?
|
||||
|
||||
/**
|
||||
An optional suffix to add to the thumbnail title of a page.
|
||||
|
||||
This can be useful to express a different author, project grouping, etc.
|
||||
*/
|
||||
let thumbnailSuffix: String?
|
||||
|
||||
/**
|
||||
A text to place in the top right corner of a large thumbnail.
|
||||
|
||||
The text should be a very short string to fit into the corner, like `soon`, or `draft`
|
||||
|
||||
- Note: This property is ignored if `thumbnailStyle` is not `large`.
|
||||
*/
|
||||
let cornerText: String?
|
||||
|
||||
/**
|
||||
The external url to use instead of automatically generating the page.
|
||||
|
||||
This property can be used for links to other parts of the site, like additional services.
|
||||
It can also be set to manually write a page.
|
||||
*/
|
||||
let externalUrl: String?
|
||||
}
|
||||
}
|
||||
|
||||
extension GenericMetadata.LocalizedMetadata: Codable {
|
||||
|
||||
private static var knownKeyList: [CodingKeys] {
|
||||
[
|
||||
.language,
|
||||
.title,
|
||||
.subtitle,
|
||||
.description,
|
||||
.linkPreviewTitle,
|
||||
.linkPreviewImage,
|
||||
.linkPreviewDescription,
|
||||
.moreLinkText,
|
||||
.backLinkText,
|
||||
.placeholderTitle,
|
||||
.placeholderText,
|
||||
.titleSuffix,
|
||||
.thumbnailSuffix,
|
||||
.cornerText,
|
||||
.externalUrl,
|
||||
]
|
||||
}
|
||||
|
||||
static var knownKeys: Set<String> {
|
||||
Set(knownKeyList.map { $0.stringValue })
|
||||
}
|
||||
}
|
||||
|
||||
extension GenericMetadata.LocalizedMetadata {
|
||||
|
||||
/**
|
||||
The mandatory minimum for a site element.
|
||||
*/
|
||||
static var mandatory: GenericMetadata.LocalizedMetadata {
|
||||
.init(
|
||||
language: "",
|
||||
title: "",
|
||||
subtitle: nil,
|
||||
description: nil,
|
||||
linkPreviewTitle: nil,
|
||||
linkPreviewImage: nil,
|
||||
linkPreviewDescription: nil,
|
||||
moreLinkText: nil,
|
||||
backLinkText: nil,
|
||||
placeholderTitle: nil,
|
||||
placeholderText: nil,
|
||||
titleSuffix: nil,
|
||||
thumbnailSuffix: nil,
|
||||
cornerText: nil,
|
||||
externalUrl: nil)
|
||||
}
|
||||
|
||||
/**
|
||||
The mandatory minimum for the root element of a site.
|
||||
*/
|
||||
static var mandatoryAtRoot: GenericMetadata.LocalizedMetadata {
|
||||
.init(language: "",
|
||||
title: "",
|
||||
subtitle: nil,
|
||||
description: nil,
|
||||
linkPreviewTitle: nil,
|
||||
linkPreviewImage: nil,
|
||||
linkPreviewDescription: nil,
|
||||
moreLinkText: nil,
|
||||
backLinkText: "",
|
||||
placeholderTitle: "",
|
||||
placeholderText: "",
|
||||
titleSuffix: nil,
|
||||
thumbnailSuffix: nil,
|
||||
cornerText: nil,
|
||||
externalUrl: nil)
|
||||
}
|
||||
|
||||
static var full: GenericMetadata.LocalizedMetadata {
|
||||
.init(language: "",
|
||||
title: "",
|
||||
subtitle: "",
|
||||
description: "",
|
||||
linkPreviewTitle: "",
|
||||
linkPreviewImage: "",
|
||||
linkPreviewDescription: "",
|
||||
moreLinkText: "",
|
||||
backLinkText: "",
|
||||
placeholderTitle: "",
|
||||
placeholderText: "",
|
||||
titleSuffix: "",
|
||||
thumbnailSuffix: "",
|
||||
cornerText: "",
|
||||
externalUrl: "")
|
||||
}
|
||||
}
|
216
Sources/Generator/Content/GenericMetadata.swift
Normal file
216
Sources/Generator/Content/GenericMetadata.swift
Normal file
@ -0,0 +1,216 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
The metadata for all site elements.
|
||||
*/
|
||||
struct GenericMetadata {
|
||||
|
||||
/**
|
||||
The name of the metadata file contained in the folder of each site element.
|
||||
*/
|
||||
static let metadataFileName = "metadata.json"
|
||||
|
||||
/**
|
||||
A custom id to uniquely identify the element on the site.
|
||||
|
||||
The id is used for short-hand links to pages, in the form of ``
|
||||
for thumbnail previews or `[text](page:page_id)` for simple links.
|
||||
|
||||
If no custom id is set, then the name of the element folder is used.
|
||||
*/
|
||||
let customId: 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: String?
|
||||
|
||||
/**
|
||||
The end date of the element.
|
||||
|
||||
This property can be used to specify a date range for a content page.
|
||||
*/
|
||||
let endDate: String?
|
||||
|
||||
/**
|
||||
The deployment state of the page.
|
||||
|
||||
- Note: This property defaults to ``PageState.standard`
|
||||
*/
|
||||
let state: String?
|
||||
|
||||
/**
|
||||
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: Set<String>?
|
||||
|
||||
/**
|
||||
The style of thumbnail to use when generating overviews.
|
||||
|
||||
- Note: This property is only relevant for sections.
|
||||
- Note: This property is inherited from the parent if not specified.
|
||||
*/
|
||||
let thumbnailStyle: String?
|
||||
|
||||
/**
|
||||
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: String?
|
||||
|
||||
/**
|
||||
The localized metadata for each language.
|
||||
*/
|
||||
let languages: [LocalizedMetadata]?
|
||||
}
|
||||
|
||||
extension GenericMetadata: Codable {
|
||||
|
||||
private static var knownKeyList: [CodingKeys] {
|
||||
[
|
||||
.customId,
|
||||
.author,
|
||||
.topBarTitle,
|
||||
.date,
|
||||
.endDate,
|
||||
.state,
|
||||
.sortIndex,
|
||||
.externalFiles,
|
||||
.requiredFiles,
|
||||
.images,
|
||||
.thumbnailStyle,
|
||||
.useManualSorting,
|
||||
.overviewItemCount,
|
||||
.headerType,
|
||||
.languages,
|
||||
]
|
||||
}
|
||||
|
||||
static var knownKeys: Set<String> {
|
||||
Set(knownKeyList.map { $0.stringValue })
|
||||
}
|
||||
}
|
||||
|
||||
extension GenericMetadata {
|
||||
|
||||
/**
|
||||
Decode metadata in a folder.
|
||||
|
||||
- Parameter data: The binary data of the metadata file.
|
||||
- Parameter source: The path to the metadata file, relative to the source root
|
||||
- Note: The decoding routine also checks for unknown properties, and writes them to the output.
|
||||
- Note: Uses global objects
|
||||
*/
|
||||
init?(source: String) {
|
||||
guard let data = files.dataOfOptionalFile(atPath: source, source: source) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
|
||||
let knownKeys = GenericMetadata.knownKeys
|
||||
let knownLocalizedKeys = LocalizedMetadata.knownKeys
|
||||
decoder.keyDecodingStrategy = .custom { keys in
|
||||
let key = keys.last!
|
||||
// Only one key means we are decoding the generic metadata
|
||||
guard keys.count > 1 else {
|
||||
if !knownKeys.contains(key.stringValue) {
|
||||
log.unknown(property: key.stringValue, source: source)
|
||||
}
|
||||
return key
|
||||
}
|
||||
// Two levels means we're decoding the localized metadata
|
||||
if !knownLocalizedKeys.contains(key.stringValue) {
|
||||
log.unknown(property: key.stringValue, source: source)
|
||||
}
|
||||
return key
|
||||
}
|
||||
do {
|
||||
self = try decoder.decode(from: data)
|
||||
} catch {
|
||||
print("Here \(data)")
|
||||
log.failedToOpen(GenericMetadata.metadataFileName, requiredBy: source, error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension GenericMetadata {
|
||||
|
||||
static var full: GenericMetadata {
|
||||
.init(
|
||||
customId: "",
|
||||
author: "",
|
||||
topBarTitle: "",
|
||||
date: "",
|
||||
endDate: "",
|
||||
state: "",
|
||||
sortIndex: 1,
|
||||
externalFiles: [],
|
||||
requiredFiles: [],
|
||||
images: [],
|
||||
thumbnailStyle: "",
|
||||
useManualSorting: false,
|
||||
overviewItemCount: 6,
|
||||
headerType: "left",
|
||||
languages: [.full])
|
||||
}
|
||||
}
|
19
Sources/Generator/Content/HeaderType.swift
Normal file
19
Sources/Generator/Content/HeaderType.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum HeaderType: String {
|
||||
|
||||
/**
|
||||
The standard page header, left-aligned
|
||||
*/
|
||||
case left
|
||||
|
||||
/**
|
||||
The standard overview header, centered
|
||||
*/
|
||||
case center
|
||||
|
||||
/**
|
||||
The element provides it's own header, so don't generate any.
|
||||
*/
|
||||
case none
|
||||
}
|
41
Sources/Generator/Content/PageState.swift
Normal file
41
Sources/Generator/Content/PageState.swift
Normal file
@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
enum PageState: String {
|
||||
/**
|
||||
Generate the page, and show it in overviews of the parent.
|
||||
*/
|
||||
case standard
|
||||
|
||||
/**
|
||||
Generate the page, but don't provide links in overviews.
|
||||
*/
|
||||
case draft
|
||||
|
||||
/**
|
||||
Generate the page, but don't include it in overviews of the parent.
|
||||
*/
|
||||
case hidden
|
||||
}
|
||||
|
||||
extension PageState {
|
||||
|
||||
var isShownInOverview: Bool {
|
||||
switch self {
|
||||
case .standard, .draft:
|
||||
return true
|
||||
case .hidden:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var hasThumbnailLink: Bool {
|
||||
switch self {
|
||||
case .standard:
|
||||
return true
|
||||
case .draft:
|
||||
return false
|
||||
case .hidden:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
35
Sources/Generator/Content/ThumbnailStyle.swift
Normal file
35
Sources/Generator/Content/ThumbnailStyle.swift
Normal file
@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
enum ThumbnailStyle: String, CaseIterable {
|
||||
|
||||
case large
|
||||
|
||||
case square
|
||||
|
||||
case small
|
||||
|
||||
var height: Int {
|
||||
switch self {
|
||||
case .large:
|
||||
return 210
|
||||
case .square:
|
||||
return 178
|
||||
case .small:
|
||||
return 78
|
||||
}
|
||||
}
|
||||
var width: Int {
|
||||
switch self {
|
||||
case .large:
|
||||
return 374
|
||||
case .square:
|
||||
return height
|
||||
case .small:
|
||||
return height
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ThumbnailStyle: Codable {
|
||||
|
||||
}
|
Reference in New Issue
Block a user