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 {
|
||||
|
||||
}
|
9
Sources/Generator/Extensions/Data+Extensions.swift
Normal file
9
Sources/Generator/Extensions/Data+Extensions.swift
Normal file
@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
extension Data {
|
||||
|
||||
func createFolderAndWrite(to url: URL) throws {
|
||||
try url.ensureParentFolderExistence()
|
||||
try write(to: url)
|
||||
}
|
||||
}
|
8
Sources/Generator/Extensions/Decodable+Extensions.swift
Normal file
8
Sources/Generator/Extensions/Decodable+Extensions.swift
Normal file
@ -0,0 +1,8 @@
|
||||
import Foundation
|
||||
|
||||
extension JSONDecoder {
|
||||
|
||||
func decode<T>(from data: Data) throws -> T where T: Decodable {
|
||||
try self.decode(T.self, from: data)
|
||||
}
|
||||
}
|
15
Sources/Generator/Extensions/NSImage+Extensions.swift
Normal file
15
Sources/Generator/Extensions/NSImage+Extensions.swift
Normal file
@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
extension NSImage {
|
||||
|
||||
func scaledDown(to size: NSSize) -> NSImage {
|
||||
guard self.size.width > size.width else {
|
||||
return self
|
||||
}
|
||||
return NSImage(size: size, flipped: false) { (resizedRect) -> Bool in
|
||||
self.draw(in: resizedRect)
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
28
Sources/Generator/Extensions/NSSize+Extensions.swift
Normal file
28
Sources/Generator/Extensions/NSSize+Extensions.swift
Normal file
@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
|
||||
extension NSSize {
|
||||
|
||||
func scaledDown(to desiredWidth: CGFloat) -> NSSize {
|
||||
if width == desiredWidth {
|
||||
return self
|
||||
}
|
||||
|
||||
if width < desiredWidth {
|
||||
// Don't scale larger
|
||||
return self
|
||||
}
|
||||
|
||||
let height = height * desiredWidth / width
|
||||
return NSSize(width: desiredWidth, height: height)
|
||||
}
|
||||
}
|
||||
|
||||
extension NSSize {
|
||||
|
||||
var ratio: CGFloat {
|
||||
guard height != 0 else {
|
||||
return 0
|
||||
}
|
||||
return width / height
|
||||
}
|
||||
}
|
28
Sources/Generator/Extensions/Optional+Extensions.swift
Normal file
28
Sources/Generator/Extensions/Optional+Extensions.swift
Normal file
@ -0,0 +1,28 @@
|
||||
import Foundation
|
||||
import Metal
|
||||
|
||||
extension Optional {
|
||||
|
||||
func unwrapped<T>(_ closure: (Wrapped) -> T) -> T? {
|
||||
if case let .some(value) = self {
|
||||
return closure(value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ifNil(_ closure: () -> Void) -> Self {
|
||||
if self == nil {
|
||||
closure()
|
||||
}
|
||||
return self
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ifNotNil(_ closure: () -> Void) -> Self {
|
||||
if self != nil {
|
||||
closure()
|
||||
}
|
||||
return self
|
||||
}
|
||||
}
|
76
Sources/Generator/Extensions/String+Extensions.swift
Normal file
76
Sources/Generator/Extensions/String+Extensions.swift
Normal file
@ -0,0 +1,76 @@
|
||||
import Foundation
|
||||
|
||||
extension String {
|
||||
|
||||
var nonEmpty: String? {
|
||||
self.isEmpty ? nil : self
|
||||
}
|
||||
|
||||
var trimmed: String {
|
||||
trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
func indented(by indentation: String) -> String {
|
||||
components(separatedBy: "\n").joined(separator: "\n" + indentation)
|
||||
}
|
||||
|
||||
var withoutEmptyLines: String {
|
||||
components(separatedBy: "\n")
|
||||
.filter { !$0.trimmed.isEmpty }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func dropAfterLast(_ separator: String) -> String {
|
||||
guard contains(separator) else {
|
||||
return self
|
||||
}
|
||||
return components(separatedBy: separator).dropLast().joined(separator: separator)
|
||||
}
|
||||
|
||||
func dropBeforeFirst(_ separator: String) -> String {
|
||||
guard contains(separator) else {
|
||||
return self
|
||||
}
|
||||
return components(separatedBy: separator).dropFirst().joined(separator: separator)
|
||||
}
|
||||
|
||||
func lastComponentAfter(_ separator: String) -> String {
|
||||
components(separatedBy: separator).last!
|
||||
}
|
||||
|
||||
/**
|
||||
Insert the new content before the last occurence of the specified separator.
|
||||
|
||||
If the separator does not appear in the string, then the new content is simply appended.
|
||||
*/
|
||||
func insert(_ content: String, beforeLast separator: String) -> String {
|
||||
let parts = components(separatedBy: separator)
|
||||
guard parts.count > 1 else {
|
||||
return self + content
|
||||
}
|
||||
return parts.dropLast().joined(separator: separator) + content + separator + parts.last!
|
||||
}
|
||||
|
||||
func dropAfterFirst<T>(_ separator: T) -> String where T: StringProtocol {
|
||||
components(separatedBy: separator).first!
|
||||
}
|
||||
|
||||
func between(_ start: String, and end: String) -> String {
|
||||
dropBeforeFirst(start).dropAfterFirst(end)
|
||||
}
|
||||
}
|
||||
|
||||
extension Substring {
|
||||
|
||||
func between(_ start: String, and end: String) -> String {
|
||||
components(separatedBy: start).last!
|
||||
.components(separatedBy: end).first!
|
||||
}
|
||||
}
|
||||
|
||||
extension String {
|
||||
|
||||
func createFolderAndWrite(to url: URL) throws {
|
||||
try data(using: .utf8)!.createFolderAndWrite(to: url)
|
||||
}
|
||||
}
|
53
Sources/Generator/Extensions/URL+Extensions.swift
Normal file
53
Sources/Generator/Extensions/URL+Extensions.swift
Normal file
@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
|
||||
extension URL {
|
||||
|
||||
func ensureParentFolderExistence() throws {
|
||||
try deletingLastPathComponent().ensureFolderExistence()
|
||||
}
|
||||
|
||||
func ensureFolderExistence() throws {
|
||||
guard !exists else {
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(at: self, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
var isDirectory: Bool {
|
||||
do {
|
||||
let resources = try resourceValues(forKeys: [.isDirectoryKey])
|
||||
guard let isDirectory = resources.isDirectory else {
|
||||
print("No isDirectory info for \(path)")
|
||||
return false
|
||||
}
|
||||
return isDirectory
|
||||
} catch {
|
||||
print("Failed to get directory information from \(path): \(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var exists: Bool {
|
||||
FileManager.default.fileExists(atPath: path)
|
||||
}
|
||||
|
||||
/**
|
||||
Delete the file at the url.
|
||||
*/
|
||||
func delete() throws {
|
||||
try FileManager.default.removeItem(at: self)
|
||||
}
|
||||
|
||||
func copy(to url: URL) throws {
|
||||
if url.exists {
|
||||
try url.delete()
|
||||
}
|
||||
try url.ensureParentFolderExistence()
|
||||
try FileManager.default.copyItem(at: self, to: url)
|
||||
}
|
||||
|
||||
var size: Int? {
|
||||
let attributes = try? FileManager.default.attributesOfItem(atPath: path)
|
||||
return (attributes?[.size] as? NSNumber)?.intValue
|
||||
}
|
||||
}
|
59
Sources/Generator/Files/Configuration.swift
Normal file
59
Sources/Generator/Files/Configuration.swift
Normal file
@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
The global configuration of the website.
|
||||
*/
|
||||
struct Configuration: Codable {
|
||||
|
||||
/**
|
||||
The width of page content in pixels.
|
||||
|
||||
The width specification is used to scale images to the correct width,
|
||||
when images are included in markdown content using the syntax
|
||||
``.
|
||||
- Note: A high-resolution `@2x` version will be generated as well.
|
||||
*/
|
||||
let pageImageWidth: Int
|
||||
|
||||
/**
|
||||
Automatically minify all `.css` and `.js` resources which are copied
|
||||
to the output folder.
|
||||
- Note: This option requires the `uglifyjs` and `clean-css` tools,
|
||||
which can be installed using the `install.sh` script in the root folder of the generator.
|
||||
*/
|
||||
let minifyCSSandJS: Bool
|
||||
|
||||
/**
|
||||
The path to the directory where the root element metadata is located.
|
||||
*/
|
||||
let contentPath: String
|
||||
|
||||
/**
|
||||
The path where the generated website should be written.
|
||||
*/
|
||||
let outputPath: String
|
||||
|
||||
/**
|
||||
Create .md files for content pages, if they don't exist.
|
||||
|
||||
After the languages of the root element are read, the generator looks
|
||||
for localized `.md` files for each page element which has metadata.
|
||||
If it can't find a content file, it generates a placeholder.
|
||||
|
||||
Setting this option to `true` will cause the generator to create empty `.md`
|
||||
files for each root level language. This can be helpful to see which content still needs
|
||||
to be written. There is then also no need to manually create these files.
|
||||
|
||||
- Note: Empty content files will continue to be ignored by the generator,
|
||||
and treated as if they are not present.
|
||||
*/
|
||||
let createMdFilesIfMissing: Bool
|
||||
|
||||
var contentDirectory: URL {
|
||||
.init(fileURLWithPath: contentPath)
|
||||
}
|
||||
|
||||
var outputDirectory: URL {
|
||||
.init(fileURLWithPath: outputPath)
|
||||
}
|
||||
}
|
27
Sources/Generator/Files/ContentError.swift
Normal file
27
Sources/Generator/Files/ContentError.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
|
||||
struct ContentError: Error {
|
||||
|
||||
let reason: String
|
||||
|
||||
let source: String
|
||||
|
||||
let error: Error?
|
||||
}
|
||||
|
||||
extension Optional {
|
||||
|
||||
func unwrapped(or error: ContentError) throws -> Wrapped {
|
||||
guard case let .some(value) = self else {
|
||||
throw error
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func unwrapOrFail(_ reason: String, source: String, error: Error? = nil) throws -> Wrapped {
|
||||
guard case let .some(value) = self else {
|
||||
throw ContentError(reason: reason, source: source, error: error)
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
648
Sources/Generator/Files/FileSystem.swift
Normal file
648
Sources/Generator/Files/FileSystem.swift
Normal file
@ -0,0 +1,648 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import AppKit
|
||||
|
||||
typealias SourceFile = (data: Data, didChange: Bool)
|
||||
typealias SourceTextFile = (content: String, didChange: Bool)
|
||||
|
||||
final class FileSystem {
|
||||
|
||||
private static let tempFileName = "temp.bin"
|
||||
|
||||
private static let hashesFileName = "hashes.json"
|
||||
|
||||
private let input: URL
|
||||
|
||||
private let output: URL
|
||||
|
||||
private let source = "FileChangeMonitor"
|
||||
|
||||
private var hashesFile: URL {
|
||||
input.appendingPathComponent(FileSystem.hashesFileName)
|
||||
}
|
||||
|
||||
private var tempFile: URL {
|
||||
input.appendingPathComponent(FileSystem.tempFileName)
|
||||
}
|
||||
|
||||
/**
|
||||
The hashes of all accessed files from the previous run
|
||||
|
||||
The key is the relative path to the file from the source
|
||||
*/
|
||||
private var previousFiles: [String : Data] = [:]
|
||||
|
||||
/**
|
||||
The paths of all files which were accessed, with their new hashes
|
||||
|
||||
This list is used to check if a file was modified, and to write all accessed files back to disk
|
||||
*/
|
||||
private var accessedFiles: [String : Data] = [:]
|
||||
|
||||
/**
|
||||
All files which should be copied to the output folder
|
||||
*/
|
||||
private var requiredFiles: Set<String> = []
|
||||
|
||||
/**
|
||||
The files marked as external in element metadata.
|
||||
|
||||
Files included here are not generated, since they are assumed to be added separately.
|
||||
*/
|
||||
private var externalFiles: Set<String> = []
|
||||
|
||||
/**
|
||||
The files marked as expected, i.e. they exist after the generation is completed.
|
||||
|
||||
The key of the dictionary is the file path, the value is the file providing the link
|
||||
*/
|
||||
private var expectedFiles: [String : String] = [:]
|
||||
|
||||
/**
|
||||
All pages without content which have been created
|
||||
*/
|
||||
private var emptyPages: Set<String> = []
|
||||
|
||||
/**
|
||||
All pages which have `status` set to ``PageState.draft``
|
||||
*/
|
||||
private var draftPages: Set<String> = []
|
||||
|
||||
/**
|
||||
All paths to page element folders, indexed by their unique id.
|
||||
|
||||
This relation is used to generate relative links to pages using the ``Element.id`
|
||||
*/
|
||||
private var pagePaths: [String: String] = [:]
|
||||
|
||||
/**
|
||||
The image creation tasks.
|
||||
|
||||
The key is the destination path.
|
||||
*/
|
||||
private var imageTasks: [String : ImageOutput] = [:]
|
||||
|
||||
/**
|
||||
The paths to all pages which were changed
|
||||
*/
|
||||
private var generatedPages: Set<String> = []
|
||||
|
||||
init(in input: URL, to output: URL) {
|
||||
self.input = input
|
||||
self.output = output
|
||||
|
||||
guard exists(hashesFile) else {
|
||||
log.add(info: "No file hashes loaded, regarding all content as new", source: source)
|
||||
return
|
||||
}
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: hashesFile)
|
||||
} catch {
|
||||
log.add(
|
||||
warning: "File hashes could not be read, regarding all content as new",
|
||||
source: source,
|
||||
error: error)
|
||||
return
|
||||
}
|
||||
do {
|
||||
self.previousFiles = try JSONDecoder().decode(from: data)
|
||||
} catch {
|
||||
log.add(
|
||||
warning: "File hashes could not be decoded, regarding all content as new",
|
||||
source: source,
|
||||
error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func urlInOutputFolder(_ path: String) -> URL {
|
||||
output.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
func urlInContentFolder(_ path: String) -> URL {
|
||||
input.appendingPathComponent(path)
|
||||
}
|
||||
|
||||
/**
|
||||
Get the current hash of file data at a path.
|
||||
|
||||
If the hash has been computed previously during the current run, then this function directly returns it.
|
||||
*/
|
||||
private func hash(_ data: Data, at path: String) -> Data {
|
||||
accessedFiles[path] ?? SHA256.hash(data: data).data
|
||||
}
|
||||
|
||||
private func exists(_ url: URL) -> Bool {
|
||||
FileManager.default.fileExists(atPath: url.path)
|
||||
}
|
||||
|
||||
func dataOfRequiredFile(atPath path: String, source: String) -> Data? {
|
||||
let url = input.appendingPathComponent(path)
|
||||
guard exists(url) else {
|
||||
log.failedToOpen(path, requiredBy: source, error: nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try Data(contentsOf: url)
|
||||
} catch {
|
||||
log.failedToOpen(path, requiredBy: source, error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func dataOfOptionalFile(atPath path: String, source: String) -> Data? {
|
||||
let url = input.appendingPathComponent(path)
|
||||
guard exists(url) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try Data(contentsOf: url)
|
||||
} catch {
|
||||
log.failedToOpen(path, requiredBy: source, error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func contentOfOptionalFile(atPath path: String, source: String, createEmptyFileIfMissing: Bool = false) -> String? {
|
||||
let url = input.appendingPathComponent(path)
|
||||
guard exists(url) else {
|
||||
if createEmptyFileIfMissing {
|
||||
try? Data().write(to: url)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
do {
|
||||
return try String(contentsOf: url)
|
||||
} catch {
|
||||
log.failedToOpen(path, requiredBy: source, error: error)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func getData(atPath path: String) -> SourceFile? {
|
||||
let url = input.appendingPathComponent(path)
|
||||
guard exists(url) else {
|
||||
return nil
|
||||
}
|
||||
let data: Data
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
} catch {
|
||||
log.add(error: "Failed to read data at \(path)", source: source, error: error)
|
||||
return nil
|
||||
}
|
||||
let newHash = hash(data, at: path)
|
||||
defer {
|
||||
accessedFiles[path] = newHash
|
||||
}
|
||||
guard let oldHash = previousFiles[path] else {
|
||||
return (data: data, didChange: true)
|
||||
}
|
||||
return (data: data, didChange: oldHash != newHash)
|
||||
}
|
||||
|
||||
func writeHashes() {
|
||||
do {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = .prettyPrinted
|
||||
let data = try encoder.encode(accessedFiles)
|
||||
try data.write(to: hashesFile)
|
||||
} catch {
|
||||
log.add(warning: "Failed to save file hashes", source: source, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Images
|
||||
|
||||
private func loadImage(atPath path: String) -> (image: NSImage, changed: Bool)? {
|
||||
guard let (data, changed) = getData(atPath: path) else {
|
||||
log.add(error: "Failed to load file", source: path)
|
||||
return nil
|
||||
}
|
||||
guard let image = NSImage(data: data) else {
|
||||
log.add(error: "Failed to read image", source: path)
|
||||
return nil
|
||||
}
|
||||
return (image, changed)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func requireImage(source: String, destination: String, width: Int, desiredHeight: Int? = nil) -> NSSize {
|
||||
let height = desiredHeight.unwrapped(CGFloat.init)
|
||||
let sourceUrl = input.appendingPathComponent(source)
|
||||
let image = ImageOutput(source: source, width: width, desiredHeight: desiredHeight)
|
||||
|
||||
let standardSize = NSSize(width: CGFloat(width), height: height ?? CGFloat(width) / 16 * 9)
|
||||
guard sourceUrl.exists else {
|
||||
log.add(error: "Missing file with size (\(width),\(desiredHeight ?? -1))",
|
||||
source: source)
|
||||
return standardSize
|
||||
}
|
||||
guard let imageSize = loadImage(atPath: image.source)?.image.size else {
|
||||
log.add(error: "Unreadable image with size (\(width),\(desiredHeight ?? -1))",
|
||||
source: source)
|
||||
return standardSize
|
||||
}
|
||||
let scaledSize = imageSize.scaledDown(to: CGFloat(width))
|
||||
|
||||
guard let existing = imageTasks[destination] else {
|
||||
imageTasks[destination] = image
|
||||
return scaledSize
|
||||
}
|
||||
guard existing.source == source else {
|
||||
log.add(error: "Multiple sources (\(existing.source),\(source))",
|
||||
source: destination)
|
||||
return scaledSize
|
||||
}
|
||||
guard existing.hasSimilarRatio(as: image) else {
|
||||
log.add(error: "Multiple ratios (\(existing.ratio!),\(image.ratio!))",
|
||||
source: destination)
|
||||
return scaledSize
|
||||
}
|
||||
if image.width > existing.width {
|
||||
log.add(info: "Increasing size from \(existing.width) to \(width)",
|
||||
source: destination)
|
||||
imageTasks[destination] = image
|
||||
}
|
||||
return scaledSize
|
||||
}
|
||||
|
||||
func createImages() {
|
||||
for (destination, image) in imageTasks.sorted(by: { $0.key < $1.key }) {
|
||||
createImageIfNeeded(image, for: destination)
|
||||
}
|
||||
}
|
||||
|
||||
private func createImageIfNeeded(_ image: ImageOutput, for destination: String) {
|
||||
guard let (sourceImageData, sourceImageChanged) = getData(atPath: image.source) else {
|
||||
log.add(error: "Failed to open file", source: image.source)
|
||||
return
|
||||
}
|
||||
|
||||
let destinationUrl = output.appendingPathComponent(destination)
|
||||
|
||||
// Check if image needs to be updated
|
||||
guard !destinationUrl.exists || sourceImageChanged else {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure that image file is supported
|
||||
let ext = destinationUrl.pathExtension.lowercased()
|
||||
guard ImageType(fileExtension: ext) != nil else {
|
||||
// TODO: This should never be reached, since extensions are checked before
|
||||
log.add(info: "Copying image", source: image.source)
|
||||
do {
|
||||
let sourceUrl = input.appendingPathComponent(image.source)
|
||||
try destinationUrl.ensureParentFolderExistence()
|
||||
try sourceUrl.copy(to: destinationUrl)
|
||||
} catch {
|
||||
log.add(error: "Failed to copy image", source: destination)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let sourceImage = NSImage(data: sourceImageData) else {
|
||||
log.add(error: "Failed to read file", source: image.source)
|
||||
return
|
||||
}
|
||||
|
||||
let desiredWidth = CGFloat(image.width)
|
||||
let desiredHeight = image.desiredHeight.unwrapped(CGFloat.init)
|
||||
|
||||
let destinationSize = sourceImage.size.scaledDown(to: desiredWidth)
|
||||
let scaledImage = sourceImage.scaledDown(to: destinationSize)
|
||||
let scaledSize = scaledImage.size
|
||||
|
||||
if abs(scaledImage.size.width - desiredWidth) > 2 {
|
||||
log.add(warning: "Desired width \(desiredWidth), got \(scaledSize.width)", source: destination)
|
||||
}
|
||||
if abs(destinationSize.height - scaledImage.size.height) > 2 {
|
||||
log.add(warning: "Desired height \(destinationSize.height), got \(scaledSize.height)", source: destination)
|
||||
}
|
||||
if let desiredHeight = desiredHeight {
|
||||
let desiredRatio = desiredHeight / desiredWidth
|
||||
let adjustedDesiredHeight = scaledSize.width * desiredRatio
|
||||
if abs(adjustedDesiredHeight - scaledSize.height) > 5 {
|
||||
log.add(warning: "Desired height \(desiredHeight), got \(scaledSize.height)", source: destination)
|
||||
return
|
||||
}
|
||||
}
|
||||
if scaledSize.width > desiredWidth {
|
||||
log.add(warning:" Desired width \(desiredWidth), got \(scaledSize.width)", source: destination)
|
||||
}
|
||||
|
||||
let destinationExtension = destinationUrl.pathExtension.lowercased()
|
||||
guard let type = ImageType(fileExtension: destinationExtension)?.fileType else {
|
||||
log.add(error: "No image type for extension \(destinationExtension)",
|
||||
source: destination)
|
||||
return
|
||||
}
|
||||
guard let tiff = scaledImage.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else {
|
||||
log.add(error: "Failed to get data", source: image.source)
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = tiffData.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
|
||||
log.add(error: "Failed to get data", source: image.source)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try data.createFolderAndWrite(to: destinationUrl)
|
||||
} catch {
|
||||
log.add(error: "Failed to write image \(destination)", source: "Image Processor", error: error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: File copying
|
||||
|
||||
/**
|
||||
Add a file as required, so that it will be copied to the output directory.
|
||||
*/
|
||||
func require(file: String) {
|
||||
let url = input.appendingPathComponent(file)
|
||||
guard url.exists, url.isDirectory else {
|
||||
requiredFiles.insert(file)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try FileManager.default
|
||||
.contentsOfDirectory(atPath: url.path)
|
||||
.forEach {
|
||||
// Recurse into subfolders
|
||||
require(file: file + "/" + $0)
|
||||
}
|
||||
} catch {
|
||||
log.add(error: "Failed to read folder \(file): \(error)", source: source)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Mark a file as explicitly missing.
|
||||
|
||||
This is done for the `externalFiles` entries in metadata,
|
||||
to indicate that these files will be copied to the output folder manually.
|
||||
*/
|
||||
func exclude(file: String) {
|
||||
externalFiles.insert(file)
|
||||
}
|
||||
|
||||
/**
|
||||
Mark a file as expected to be present in the output folder after generation.
|
||||
|
||||
This is done for all links between pages, which only exist after the pages have been generated.
|
||||
*/
|
||||
func expect(file: String, source: String) {
|
||||
expectedFiles[file] = source
|
||||
}
|
||||
|
||||
func copyRequiredFiles() {
|
||||
var copiedFiles = Set<String>()
|
||||
for file in requiredFiles {
|
||||
let cleanPath = cleanRelativeURL(file)
|
||||
let sourceUrl = input.appendingPathComponent(cleanPath)
|
||||
let destinationUrl = output.appendingPathComponent(cleanPath)
|
||||
guard sourceUrl.exists else {
|
||||
if !isExternal(file: file) {
|
||||
log.add(error: "Missing required file", source: cleanPath)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if copyFileIfChanged(from: sourceUrl, to: destinationUrl) {
|
||||
copiedFiles.insert(file)
|
||||
}
|
||||
}
|
||||
try? tempFile.delete()
|
||||
for (file, source) in expectedFiles {
|
||||
guard !isExternal(file: file) else {
|
||||
continue
|
||||
}
|
||||
let cleanPath = cleanRelativeURL(file)
|
||||
let destinationUrl = output.appendingPathComponent(cleanPath)
|
||||
if !destinationUrl.exists {
|
||||
log.add(error: "Missing \(cleanPath)", source: source)
|
||||
}
|
||||
}
|
||||
guard !copiedFiles.isEmpty else {
|
||||
print("No required files copied")
|
||||
return
|
||||
}
|
||||
print("\(copiedFiles.count) required files copied:")
|
||||
for file in copiedFiles.sorted() {
|
||||
print(" " + file)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyFileIfChanged(from sourceUrl: URL, to destinationUrl: URL) -> Bool {
|
||||
guard configuration.minifyCSSandJS else {
|
||||
return copyBinaryFileIfChanged(from: sourceUrl, to: destinationUrl)
|
||||
}
|
||||
switch sourceUrl.pathExtension.lowercased() {
|
||||
case "js":
|
||||
return minifyJS(at: sourceUrl, andWriteTo: destinationUrl)
|
||||
case "css":
|
||||
return minifyCSS(at: sourceUrl, andWriteTo: destinationUrl)
|
||||
default:
|
||||
return copyBinaryFileIfChanged(from: sourceUrl, to: destinationUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyBinaryFileIfChanged(from sourceUrl: URL, to destinationUrl: URL) -> Bool {
|
||||
do {
|
||||
let data = try Data(contentsOf: sourceUrl)
|
||||
return writeIfChanged(data, to: destinationUrl)
|
||||
} catch {
|
||||
log.add(error: "Failed to read data at \(sourceUrl.path)", source: source, error: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func minifyJS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool {
|
||||
let command = "uglifyjs \(sourceUrl.path) > \(tempFile.path)"
|
||||
do {
|
||||
_ = try safeShell(command)
|
||||
return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl)
|
||||
} catch {
|
||||
log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func minifyCSS(at sourceUrl: URL, andWriteTo destinationUrl: URL) -> Bool {
|
||||
let command = "cleancss \(sourceUrl.path) -o \(tempFile.path)"
|
||||
do {
|
||||
_ = try safeShell(command)
|
||||
return copyBinaryFileIfChanged(from: tempFile, to: destinationUrl)
|
||||
} catch {
|
||||
log.add(error: "Failed to minify \(sourceUrl.path): \(error)", source: source)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func cleanRelativeURL(_ raw: String) -> String {
|
||||
let raw = raw.dropAfterLast("#") // Clean links to page content
|
||||
guard raw.contains("..") else {
|
||||
return raw
|
||||
}
|
||||
var result: [String] = []
|
||||
for component in raw.components(separatedBy: "/") {
|
||||
if component == ".." {
|
||||
_ = result.popLast()
|
||||
} else {
|
||||
result.append(component)
|
||||
}
|
||||
}
|
||||
return result.joined(separator: "/")
|
||||
}
|
||||
|
||||
/**
|
||||
Check if a file is marked as external.
|
||||
|
||||
Also checks for sub-paths of the file, e.g if the folder `docs` is marked as external,
|
||||
then files like `docs/index.html` are also found to be external.
|
||||
- Note: All paths are either relative to root (no leading slash) or absolute paths of the domain (leading slash)
|
||||
*/
|
||||
func isExternal(file: String) -> Bool {
|
||||
// Deconstruct file path
|
||||
var path = ""
|
||||
for part in file.components(separatedBy: "/") {
|
||||
guard part != "" else {
|
||||
continue
|
||||
}
|
||||
if path == "" {
|
||||
path = part
|
||||
} else {
|
||||
path += "/" + part
|
||||
}
|
||||
if externalFiles.contains(path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func printExternalFiles() {
|
||||
guard !externalFiles.isEmpty else {
|
||||
return
|
||||
}
|
||||
print("\(externalFiles.count) external resources needed:")
|
||||
for file in externalFiles.sorted() {
|
||||
print(" " + file)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Pages
|
||||
|
||||
func isEmpty(page: String) {
|
||||
emptyPages.insert(page)
|
||||
}
|
||||
|
||||
func printEmptyPages() {
|
||||
guard !emptyPages.isEmpty else {
|
||||
return
|
||||
}
|
||||
print("\(emptyPages.count) empty pages:")
|
||||
for page in emptyPages.sorted() {
|
||||
print(" " + page)
|
||||
}
|
||||
}
|
||||
|
||||
func isDraft(path: String) {
|
||||
draftPages.insert(path)
|
||||
}
|
||||
|
||||
func printDraftPages() {
|
||||
guard !draftPages.isEmpty else {
|
||||
return
|
||||
}
|
||||
print("\(draftPages.count) drafts:")
|
||||
for page in draftPages.sorted() {
|
||||
print(" " + page)
|
||||
}
|
||||
}
|
||||
|
||||
func add(page: String, id: String) {
|
||||
if let existing = pagePaths[id] {
|
||||
log.add(error: "Conflicting id with \(existing)", source: page)
|
||||
}
|
||||
pagePaths[id] = page
|
||||
}
|
||||
|
||||
func getPage(for id: String) -> String? {
|
||||
pagePaths[id]
|
||||
}
|
||||
|
||||
func generated(page: String) {
|
||||
generatedPages.insert(page)
|
||||
}
|
||||
|
||||
func printGeneratedPages() {
|
||||
guard !generatedPages.isEmpty else {
|
||||
print("No pages modified")
|
||||
return
|
||||
}
|
||||
print("\(generatedPages.count) pages modified")
|
||||
for page in generatedPages.sorted() {
|
||||
print(" " + page)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: Writing files
|
||||
|
||||
@discardableResult
|
||||
func writeIfChanged(_ data: Data, to url: URL) -> Bool {
|
||||
// Only write changed files
|
||||
if url.exists, let oldContent = try? Data(contentsOf: url), data == oldContent {
|
||||
return false
|
||||
}
|
||||
do {
|
||||
try data.createFolderAndWrite(to: url)
|
||||
return true
|
||||
} catch {
|
||||
log.add(error: "Failed to write file", source: url.path, error: error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func write(_ string: String, to url: URL) -> Bool {
|
||||
let data = string.data(using: .utf8)!
|
||||
return writeIfChanged(data, to: url)
|
||||
}
|
||||
|
||||
// MARK: Running other tasks
|
||||
|
||||
@discardableResult
|
||||
func safeShell(_ command: String) throws -> String {
|
||||
let task = Process()
|
||||
let pipe = Pipe()
|
||||
|
||||
task.standardOutput = pipe
|
||||
task.standardError = pipe
|
||||
task.arguments = ["-cl", command]
|
||||
task.executableURL = URL(fileURLWithPath: "/bin/zsh")
|
||||
task.standardInput = nil
|
||||
|
||||
try task.run()
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: data, encoding: .utf8)!
|
||||
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
private extension Digest {
|
||||
|
||||
var bytes: [UInt8] { Array(makeIterator()) }
|
||||
|
||||
var data: Data { Data(bytes) }
|
||||
|
||||
var hexStr: String {
|
||||
bytes.map { String(format: "%02X", $0) }.joined()
|
||||
}
|
||||
}
|
24
Sources/Generator/Files/ImageOutput.swift
Normal file
24
Sources/Generator/Files/ImageOutput.swift
Normal file
@ -0,0 +1,24 @@
|
||||
import Foundation
|
||||
|
||||
struct ImageOutput: Hashable {
|
||||
|
||||
let source: String
|
||||
|
||||
let width: Int
|
||||
|
||||
let desiredHeight: Int?
|
||||
|
||||
var ratio: Float? {
|
||||
guard let desiredHeight = desiredHeight else {
|
||||
return nil
|
||||
}
|
||||
return Float(desiredHeight) / Float(width)
|
||||
}
|
||||
|
||||
func hasSimilarRatio(as other: ImageOutput) -> Bool {
|
||||
guard let other = other.ratio, let ratio = ratio else {
|
||||
return true
|
||||
}
|
||||
return abs(other - ratio) < 0.1
|
||||
}
|
||||
}
|
27
Sources/Generator/Files/ImageType.swift
Normal file
27
Sources/Generator/Files/ImageType.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
|
||||
enum ImageType: CaseIterable {
|
||||
case jpg
|
||||
case png
|
||||
|
||||
init?(fileExtension: String) {
|
||||
switch fileExtension {
|
||||
case "jpg", "jpeg":
|
||||
self = .jpg
|
||||
case "png":
|
||||
self = .png
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var fileType: NSBitmapImageRep.FileType {
|
||||
switch self {
|
||||
case .jpg:
|
||||
return .jpeg
|
||||
case .png:
|
||||
return .png
|
||||
}
|
||||
}
|
||||
}
|
168
Sources/Generator/Files/ValidationLog.swift
Normal file
168
Sources/Generator/Files/ValidationLog.swift
Normal file
@ -0,0 +1,168 @@
|
||||
import Foundation
|
||||
|
||||
final class ValidationLog {
|
||||
|
||||
private enum LogLevel: String {
|
||||
case error = "ERROR"
|
||||
case warning = "WARNING"
|
||||
case info = "INFO"
|
||||
}
|
||||
|
||||
init() {
|
||||
|
||||
}
|
||||
|
||||
private func add(_ type: LogLevel, item: ContentError) {
|
||||
let errorText: String
|
||||
if let err = item.error {
|
||||
errorText = ", Error: \(err.localizedDescription)"
|
||||
} else {
|
||||
errorText = ""
|
||||
}
|
||||
print("[\(type.rawValue)][\(item.source)] \(item.reason)\(errorText)")
|
||||
}
|
||||
|
||||
func add(error: ContentError) {
|
||||
add(.error, item: error)
|
||||
}
|
||||
|
||||
func add(error reason: String, source: String, error: Error? = nil) {
|
||||
add(error: .init(reason: reason, source: source, error: error))
|
||||
}
|
||||
|
||||
func add(warning: ContentError) {
|
||||
add(.warning, item: warning)
|
||||
}
|
||||
|
||||
func add(warning reason: String, source: String, error: Error? = nil) {
|
||||
add(warning: .init(reason: reason, source: source, error: error))
|
||||
}
|
||||
|
||||
func add(info: ContentError) {
|
||||
add(.info, item: info)
|
||||
}
|
||||
|
||||
func add(info reason: String, source: String, error: Error? = nil) {
|
||||
add(info: .init(reason: reason, source: source, error: error))
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func unused<T, R>(_ value: Optional<T>, _ name: String, source: String) -> Optional<R> {
|
||||
if value != nil {
|
||||
add(info: "Unused property '\(name)'", source: source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unknown(property: String, source: String) {
|
||||
add(info: "Unknown property '\(property)'", source: source)
|
||||
}
|
||||
|
||||
func required<T>(_ value: Optional<T>, name: String, source: String) -> Optional<T> {
|
||||
guard let value = value else {
|
||||
add(error: "Missing property '\(name)'", source: source)
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func unexpected<T>(_ value: Optional<T>, name: String, source: String) -> Optional<T> {
|
||||
if let value = value {
|
||||
add(error: "Unexpected property '\(name)' = '\(value)'", source: source)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func missing(_ file: String, requiredBy source: String) {
|
||||
print("[ERROR] Missing file '\(file)' required by \(source)")
|
||||
}
|
||||
|
||||
func failedToOpen(_ file: String, requiredBy source: String, error: Error?) {
|
||||
print("[ERROR] Failed to open file '\(file)' required by \(source): \(error?.localizedDescription ?? "No error provided")")
|
||||
}
|
||||
|
||||
func state(_ raw: String?, source: String) -> PageState {
|
||||
guard let raw = raw else {
|
||||
return .standard
|
||||
}
|
||||
guard let state = PageState(rawValue: raw) else {
|
||||
add(warning: "Invalid 'state' '\(raw)', using 'standard'", source: source)
|
||||
return .standard
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
func headerType(_ raw: String?, source: String) -> HeaderType {
|
||||
guard let raw = raw else {
|
||||
return .left
|
||||
}
|
||||
guard let type = HeaderType(rawValue: raw) else {
|
||||
add(warning: "Invalid 'headerType' '\(raw)', using 'left'", source: source)
|
||||
return .left
|
||||
}
|
||||
return type
|
||||
}
|
||||
|
||||
func thumbnailStyle(_ raw: String?, source: String) -> ThumbnailStyle {
|
||||
guard let raw = raw else {
|
||||
return .large
|
||||
}
|
||||
guard let style = ThumbnailStyle(rawValue: raw) else {
|
||||
add(warning: "Invalid 'thumbnailStyle' '\(raw)', using 'large'", source: source)
|
||||
return .large
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
func linkPreviewThumbnail(customFile: String?, for language: String, in folder: URL, source: String) -> String? {
|
||||
if let customFile = customFile {
|
||||
let customFileUrl: URL
|
||||
if customFile.starts(with: "/") {
|
||||
customFileUrl = URL(fileURLWithPath: customFile)
|
||||
} else {
|
||||
customFileUrl = folder.appendingPathComponent(customFile)
|
||||
}
|
||||
guard customFileUrl.exists else {
|
||||
missing(customFile, requiredBy: "property 'linkPreviewImage' in metadata of \(source)")
|
||||
return nil
|
||||
}
|
||||
return customFile
|
||||
}
|
||||
guard let thumbnail = Element.findThumbnail(for: language, in: folder) else {
|
||||
// Link preview images are not necessarily required
|
||||
return nil
|
||||
}
|
||||
return thumbnail
|
||||
}
|
||||
|
||||
func moreLinkText(_ elementText: String?, parent parentText: String?, source: String) -> String {
|
||||
if let elementText = elementText {
|
||||
return elementText
|
||||
}
|
||||
let standard = Element.LocalizedMetadata.moreLinkDefaultText
|
||||
guard let parentText = parentText, parentText != standard else {
|
||||
add(error: "Missing property 'moreLinkText'", source: source)
|
||||
return standard
|
||||
}
|
||||
|
||||
return parentText
|
||||
}
|
||||
|
||||
func date(from string: String?, property: String, source: String) -> Date? {
|
||||
guard let string = string else {
|
||||
return nil
|
||||
}
|
||||
guard let date = ValidationLog.metadataDate.date(from: string) else {
|
||||
add(warning: "Invalid date string '\(string)' for property '\(property)'", source: source)
|
||||
return nil
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
private static let metadataDate: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.dateFormat = "dd.MM.yy"
|
||||
return df
|
||||
}()
|
||||
}
|
15
Sources/Generator/Files/VideoType.swift
Normal file
15
Sources/Generator/Files/VideoType.swift
Normal file
@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
enum VideoType: String, CaseIterable {
|
||||
case mp4
|
||||
case m4v
|
||||
|
||||
var htmlType: String {
|
||||
switch self {
|
||||
case .mp4:
|
||||
return "video/mp4"
|
||||
case .m4v:
|
||||
return "video/mp4"
|
||||
}
|
||||
}
|
||||
}
|
113
Sources/Generator/Generators/HTMLElementsGenerator.swift
Normal file
113
Sources/Generator/Generators/HTMLElementsGenerator.swift
Normal file
@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
|
||||
struct HTMLElementsGenerator {
|
||||
|
||||
init() {
|
||||
|
||||
}
|
||||
|
||||
func make(title: String, suffix: String) -> String {
|
||||
"\(title)<span class=\"suffix\">\(suffix)</span>"
|
||||
}
|
||||
|
||||
func topBarWebsiteTitle(language: String, from page: Element) -> String {
|
||||
guard let pathToRoot = page.pathToRoot else {
|
||||
return Element.htmlPageName(for: language)
|
||||
}
|
||||
return pathToRoot + Element.htmlPagePathAddition(for: language)
|
||||
}
|
||||
|
||||
func topBarLanguageButton(_ language: String) -> String {
|
||||
"<a href=\"\(Element.htmlPageName(for: language))\">\(language)</a>"
|
||||
}
|
||||
|
||||
func topBarNavigationLink(url: String, text: String, isActive: Bool) -> String {
|
||||
"<a\(isActive ? " class=\"active\"" : "") href=\"/\(url)\">\(text)</a>"
|
||||
}
|
||||
|
||||
func linkPreviewImage(file: String) -> String {
|
||||
"<meta property=\"og:image\" content=\"\(file)\" />"
|
||||
}
|
||||
|
||||
func makePrevText(_ text: String) -> String {
|
||||
"<span class=\"icon-back\"></span>\(text)"
|
||||
}
|
||||
|
||||
func makeNextText(_ text: String) -> String {
|
||||
"\(text)<span class=\"icon-next\"></span>"
|
||||
}
|
||||
|
||||
func svgImage(file: String) -> String {
|
||||
"""
|
||||
<span class="image">
|
||||
<img src="\(file)"/>
|
||||
</span>
|
||||
"""
|
||||
}
|
||||
|
||||
func svgImage(file: String, x: Int, y: Int, width: Int, height: Int) -> String {
|
||||
"""
|
||||
<span class="image">
|
||||
<img src="\(file)#svgView(viewBox(\(x), \(y), \(width), \(height)))" style="aspect-ratio:\(Float(width)/Float(height))"/>
|
||||
</span>
|
||||
"""
|
||||
}
|
||||
|
||||
func downloadButtons(_ buttons: [(file: String, text: String, downloadName: String?)]) -> String {
|
||||
let content = buttons.map {
|
||||
if let download = $0.downloadName {
|
||||
return button(file: $0.file, text: $0.text, downloadName: download)
|
||||
} else {
|
||||
return button(file: $0.file, text: $0.text)
|
||||
}
|
||||
}.joined(separator: "\n")
|
||||
return flexParagraph(content)
|
||||
}
|
||||
|
||||
func externalButtons(_ buttons: [(url: String, text: String)]) -> String {
|
||||
let content = buttons
|
||||
.map { externalLink(url: $0.url, text: $0.text) }
|
||||
.joined(separator: "\n")
|
||||
return flexParagraph(content)
|
||||
}
|
||||
|
||||
private func flexParagraph(_ content: String) -> String {
|
||||
"""
|
||||
<p style="display: flex">
|
||||
\(content)
|
||||
</p>
|
||||
"""
|
||||
}
|
||||
|
||||
private func button(file: String, text: String) -> String {
|
||||
"""
|
||||
<a class="download-button" href="\(file)">
|
||||
\(text)<span class="icon icon-download"></span>
|
||||
</a>
|
||||
"""
|
||||
}
|
||||
|
||||
private func button(file: String, text: String, downloadName: String) -> String {
|
||||
"""
|
||||
<a class="download-button" href="\(file)" download="\(downloadName)">
|
||||
\(text)<span class="icon icon-download"></span>
|
||||
</a>
|
||||
"""
|
||||
}
|
||||
|
||||
private func externalLink(url: String, text: String) -> String {
|
||||
"""
|
||||
<a class="download-button" href="\(url)">
|
||||
\(text)<span class="icon icon-download icon-rotate"></span>
|
||||
</a>
|
||||
"""
|
||||
}
|
||||
|
||||
func scriptInclude(path: String) -> String {
|
||||
"<script src=\"\(path)\"></script>"
|
||||
}
|
||||
|
||||
func codeHighlightFooter() -> String {
|
||||
"<script>hljs.highlightAll();</script>"
|
||||
}
|
||||
}
|
226
Sources/Generator/Generators/MarkdownProcessor.swift
Normal file
226
Sources/Generator/Generators/MarkdownProcessor.swift
Normal file
@ -0,0 +1,226 @@
|
||||
import Foundation
|
||||
import Ink
|
||||
import Splash
|
||||
|
||||
struct PageContentGenerator {
|
||||
|
||||
private let factory: TemplateFactory
|
||||
|
||||
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
|
||||
|
||||
init(factory: TemplateFactory) {
|
||||
self.factory = factory
|
||||
}
|
||||
|
||||
func generate(page: Element, language: String, content: String) -> (content: String, includesCode: Bool) {
|
||||
var hasCodeContent = false
|
||||
|
||||
let imageModifier = Modifier(target: .images) { html, markdown in
|
||||
processMarkdownImage(markdown: markdown, html: html, page: page)
|
||||
}
|
||||
let codeModifier = Modifier(target: .codeBlocks) { html, markdown in
|
||||
if markdown.starts(with: "```swift") {
|
||||
let code = markdown.between("```swift", and: "```").trimmed
|
||||
return "<pre><code>" + swift.highlight(code) + "</pre></code>"
|
||||
}
|
||||
hasCodeContent = true
|
||||
return html
|
||||
}
|
||||
let linkModifier = Modifier(target: .links) { html, markdown in
|
||||
handleLink(page: page, language: language, html: html, markdown: markdown)
|
||||
}
|
||||
let htmlModifier = Modifier(target: .html) { html, markdown in
|
||||
handleHTML(page: page, language: language, html: html, markdown: markdown)
|
||||
}
|
||||
|
||||
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier, htmlModifier])
|
||||
return (parser.html(from: content), hasCodeContent)
|
||||
}
|
||||
|
||||
private func handleLink(page: Element, language: String, html: String, markdown: Substring) -> String {
|
||||
let file = markdown.between("(", and: ")")
|
||||
if file.hasPrefix("page:") {
|
||||
let pageId = file.replacingOccurrences(of: "page:", with: "")
|
||||
guard let pagePath = files.getPage(for: pageId) else {
|
||||
log.add(warning: "Page id '\(pageId)' not found", source: page.path)
|
||||
// Remove link since the page can't be found
|
||||
return markdown.between("[", and: "]")
|
||||
}
|
||||
let fullPath = pagePath + Element.htmlPagePathAddition(for: language)
|
||||
// Adjust file path to get the page url
|
||||
let url = page.relativePathToOtherSiteElement(file: fullPath)
|
||||
return html.replacingOccurrences(of: file, with: url)
|
||||
}
|
||||
|
||||
if let filePath = page.nonAbsolutePathRelativeToRootForContainedInputFile(file) {
|
||||
// The target of the page link must be present after generation is complete
|
||||
files.expect(file: filePath, source: page.path)
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
private func handleHTML(page: Element, language: String, html: String, markdown: Substring) -> String {
|
||||
#warning("Check HTML code in markdown for required resources")
|
||||
//print("[HTML] Found in page \(page.path):")
|
||||
//print(markdown)
|
||||
// Things to check:
|
||||
// <img src=
|
||||
// <a href=
|
||||
//
|
||||
return html
|
||||
}
|
||||
|
||||
private func processMarkdownImage(markdown: Substring, html: String, page: Element) -> String {
|
||||
// Split the markdown 
|
||||
// For images: 
|
||||
// For videos: 
|
||||
// For svg with custom area: 
|
||||
// For downloads: 
|
||||
// External pages: 
|
||||
let fileAndTitle = markdown.between("(", and: ")")
|
||||
let alt = markdown.between("[", and: "]").nonEmpty
|
||||
switch alt {
|
||||
case "download":
|
||||
return handleDownloadButtons(page: page, content: fileAndTitle)
|
||||
case "external":
|
||||
return handleExternalButtons(page: page, content: fileAndTitle)
|
||||
case "html":
|
||||
return handleExternalHTML(page: page, file: fileAndTitle)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let file = fileAndTitle.dropAfterFirst(" ")
|
||||
let title = fileAndTitle.contains(" ") ? fileAndTitle.dropBeforeFirst(" ").nonEmpty : nil
|
||||
|
||||
let fileExtension = file.lastComponentAfter(".").lowercased()
|
||||
if let _ = ImageType(fileExtension: fileExtension) {
|
||||
return handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
|
||||
}
|
||||
if let _ = VideoType(rawValue: fileExtension) {
|
||||
return handleVideo(page: page, file: file, optionString: alt)
|
||||
}
|
||||
if fileExtension == "svg" {
|
||||
return handleSvg(page: page, file: file, area: alt)
|
||||
}
|
||||
return handleFile(page: page, file: file, fileExtension: fileExtension)
|
||||
}
|
||||
|
||||
private func handleImage(page: Element, file: String, rightTitle: String?, leftTitle: String?) -> String {
|
||||
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
|
||||
let size = files.requireImage(source: imagePath, destination: imagePath, width: configuration.pageImageWidth)
|
||||
|
||||
let imagePath2x = imagePath.insert("@2x", beforeLast: ".")
|
||||
let file2x = file.insert("@2x", beforeLast: ".")
|
||||
files.requireImage(source: imagePath, destination: imagePath2x, width: 2 * configuration.pageImageWidth)
|
||||
|
||||
let content: [PageImageTemplate.Key : String] = [
|
||||
.image: file,
|
||||
.image2x: file2x,
|
||||
.width: "\(Int(size.width))",
|
||||
.height: "\(Int(size.height))",
|
||||
.leftText: leftTitle ?? "",
|
||||
.rightText: rightTitle ?? ""]
|
||||
return factory.image.generate(content)
|
||||
}
|
||||
|
||||
private func handleVideo(page: Element, file: String, optionString: String?) -> String {
|
||||
let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in
|
||||
string.components(separatedBy: " ").compactMap { optionText in
|
||||
guard let optionText = optionText.trimmed.nonEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard let option = PageVideoTemplate.VideoOption(rawValue: optionText) else {
|
||||
log.add(warning: "Unknown video option \(optionText)", source: page.path)
|
||||
return nil
|
||||
}
|
||||
return option
|
||||
}
|
||||
} ?? []
|
||||
#warning("Check page folder for alternative video versions")
|
||||
let sources: [PageVideoTemplate.VideoSource] = [(url: file, type: .mp4)]
|
||||
|
||||
let filePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
files.require(file: filePath)
|
||||
return factory.video.generate(sources: sources, options: options)
|
||||
}
|
||||
|
||||
private func handleSvg(page: Element, file: String, area: String?) -> String {
|
||||
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
files.require(file: imagePath)
|
||||
|
||||
guard let area = area else {
|
||||
return factory.html.svgImage(file: file)
|
||||
}
|
||||
let parts = area.components(separatedBy: ",").map { $0.trimmed }
|
||||
guard parts.count == 4,
|
||||
let x = Int(parts[0].trimmed),
|
||||
let y = Int(parts[1].trimmed),
|
||||
let width = Int(parts[2].trimmed),
|
||||
let height = Int(parts[3].trimmed) else {
|
||||
log.add(warning: "Invalid area string for svg image", source: page.path)
|
||||
return factory.html.svgImage(file: file)
|
||||
}
|
||||
|
||||
return factory.html.svgImage(file: file, x: x, y: y, width: width, height: height)
|
||||
}
|
||||
|
||||
private func handleFile(page: Element, file: String, fileExtension: String) -> String {
|
||||
log.add(warning: "Unhandled file \(file) with extension \(fileExtension)", source: page.path)
|
||||
return ""
|
||||
}
|
||||
|
||||
private func handleDownloadButtons(page: Element, content: String) -> String {
|
||||
let buttons = content
|
||||
.components(separatedBy: ";")
|
||||
.compactMap { button -> (file: String, text: String, downloadName: String?)? in
|
||||
let parts = button.components(separatedBy: ",")
|
||||
guard parts.count == 2 || parts.count == 3 else {
|
||||
log.add(warning: "Invalid button definition", source: page.path)
|
||||
return nil
|
||||
}
|
||||
let file = parts[0].trimmed
|
||||
let title = parts[1].trimmed
|
||||
let downloadName = parts.count == 3 ? parts[2].trimmed : nil
|
||||
|
||||
// Ensure that file is available
|
||||
let filePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
files.require(file: filePath)
|
||||
|
||||
return (file, title, downloadName)
|
||||
}
|
||||
return factory.html.downloadButtons(buttons)
|
||||
}
|
||||
|
||||
private func handleExternalButtons(page: Element, content: String) -> String {
|
||||
let buttons = content
|
||||
.components(separatedBy: ";")
|
||||
.compactMap { button -> (url: String, text: String)? in
|
||||
let parts = button.components(separatedBy: ",")
|
||||
guard parts.count == 2 else {
|
||||
log.add(warning: "Invalid external link definition", source: page.path)
|
||||
return nil
|
||||
}
|
||||
let url = parts[0].trimmed
|
||||
let title = parts[1].trimmed
|
||||
|
||||
return (url, title)
|
||||
}
|
||||
return factory.html.externalButtons(buttons)
|
||||
}
|
||||
|
||||
private func handleExternalHTML(page: Element, file: String) -> String {
|
||||
let url = page.inputFolder.appendingPathComponent(file)
|
||||
guard url.exists else {
|
||||
log.add(error: "File \(file) not found", source: page.path)
|
||||
return ""
|
||||
}
|
||||
do {
|
||||
return try String(contentsOf: url)
|
||||
} catch {
|
||||
log.add(error: "File \(file) could not be read", source: page.path, error: error)
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
52
Sources/Generator/Generators/OverviewPageGenerator.swift
Normal file
52
Sources/Generator/Generators/OverviewPageGenerator.swift
Normal file
@ -0,0 +1,52 @@
|
||||
import Foundation
|
||||
|
||||
struct OverviewPageGenerator {
|
||||
|
||||
private let factory: LocalizedSiteTemplate
|
||||
|
||||
init(factory: LocalizedSiteTemplate) {
|
||||
self.factory = factory
|
||||
}
|
||||
|
||||
func generate(
|
||||
section: Element,
|
||||
language: String) {
|
||||
let path = section.localizedPath(for: language)
|
||||
let url = files.urlInOutputFolder(path)
|
||||
|
||||
let metadata = section.localized(for: language)
|
||||
|
||||
var content = [PageTemplate.Key : String]()
|
||||
content[.head] = factory.pageHead.generate(page: section, language: language)
|
||||
let languageButton = section.nextLanguage(for: language)
|
||||
content[.topBar] = factory.topBar.generate(
|
||||
sectionUrl: section.sectionUrl(for: language),
|
||||
languageButton: languageButton,
|
||||
page: section)
|
||||
content[.contentClass] = "overview"
|
||||
content[.header] = makeHeader(page: section, metadata: metadata, language: language)
|
||||
content[.content] = makeContent(section: section, language: language)
|
||||
content[.footer] = section.customFooterContent()
|
||||
guard factory.page.generate(content, to: url) else {
|
||||
return
|
||||
}
|
||||
files.generated(page: path)
|
||||
}
|
||||
|
||||
private func makeContent(section: Element, language: String) -> String {
|
||||
if section.hasNestingElements {
|
||||
return factory.overviewSection.generate(
|
||||
sections: section.sortedItems,
|
||||
in: section,
|
||||
language: language,
|
||||
sectionItemCount: section.overviewItemCount)
|
||||
} else {
|
||||
return factory.overviewSection.generate(section: section, language: language)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHeader(page: Element, metadata: Element.LocalizedMetadata, language: String) -> String {
|
||||
let content = factory.makeHeaderContent(page: page, metadata: metadata, language: language)
|
||||
return factory.factory.centeredHeader.generate(content)
|
||||
}
|
||||
}
|
47
Sources/Generator/Generators/OverviewSectionGenerator.swift
Normal file
47
Sources/Generator/Generators/OverviewSectionGenerator.swift
Normal file
@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
struct OverviewSectionGenerator {
|
||||
|
||||
private let multipleSectionsTemplate: OverviewSectionTemplate
|
||||
|
||||
private let singleSectionsTemplate: OverviewSectionCleanTemplate
|
||||
|
||||
private let generator: ThumbnailListGenerator
|
||||
|
||||
init(factory: TemplateFactory) {
|
||||
self.multipleSectionsTemplate = factory.overviewSection
|
||||
self.singleSectionsTemplate = factory.overviewSectionClean
|
||||
self.generator = ThumbnailListGenerator(factory: factory)
|
||||
}
|
||||
|
||||
func generate(sections: [Element], in parent: Element, language: String, sectionItemCount: Int) -> String {
|
||||
sections.map { section in
|
||||
let metadata = section.localized(for: language)
|
||||
let fullUrl = section.fullPageUrl(for: language)
|
||||
let relativeUrl = parent.relativePathToFileWithPath(fullUrl)
|
||||
|
||||
var content = [OverviewSectionTemplate.Key : String]()
|
||||
content[.url] = relativeUrl
|
||||
content[.title] = metadata.title
|
||||
content[.items] = generator.generateContent(
|
||||
items: section.itemsForOverview(sectionItemCount),
|
||||
parent: parent,
|
||||
language: language,
|
||||
style: section.thumbnailStyle)
|
||||
content[.more] = metadata.moreLinkText
|
||||
|
||||
return multipleSectionsTemplate.generate(content)
|
||||
}
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
func generate(section: Element, language: String) -> String {
|
||||
var content = [OverviewSectionCleanTemplate.Key : String]()
|
||||
content[.items] = generator.generateContent(
|
||||
items: section.itemsForOverview(),
|
||||
parent: section,
|
||||
language: language,
|
||||
style: section.thumbnailStyle)
|
||||
return singleSectionsTemplate.generate(content)
|
||||
}
|
||||
}
|
88
Sources/Generator/Generators/PageGenerator.swift
Normal file
88
Sources/Generator/Generators/PageGenerator.swift
Normal file
@ -0,0 +1,88 @@
|
||||
import Foundation
|
||||
import Ink
|
||||
|
||||
struct PageGenerator {
|
||||
|
||||
struct NavigationLink {
|
||||
|
||||
let link: String
|
||||
|
||||
let text: String
|
||||
}
|
||||
|
||||
private let factory: LocalizedSiteTemplate
|
||||
|
||||
init(factory: LocalizedSiteTemplate) {
|
||||
self.factory = factory
|
||||
}
|
||||
|
||||
func generate(page: Element, language: String, nextPage: NavigationLink?, previousPage: NavigationLink?) {
|
||||
guard !page.isExternalPage else {
|
||||
return
|
||||
}
|
||||
let path = page.fullPageUrl(for: language)
|
||||
let inputContentPath = page.path + "/\(language).md"
|
||||
let metadata = page.localized(for: language)
|
||||
let nextLanguage = page.nextLanguage(for: language)
|
||||
let (pageContent, pageIncludesCode, pageIsEmpty) = makeContent(
|
||||
page: page, metadata: metadata, language: language, path: inputContentPath)
|
||||
|
||||
var content = [PageTemplate.Key : String]()
|
||||
content[.head] = factory.pageHead.generate(page: page, language: language, includesCode: pageIncludesCode)
|
||||
let sectionUrl = page.sectionUrl(for: language)
|
||||
content[.topBar] = factory.topBar.generate(sectionUrl: sectionUrl, languageButton: nextLanguage, page: page)
|
||||
content[.contentClass] = "content"
|
||||
|
||||
content[.header] = makeHeader(page: page, metadata: metadata, language: language)
|
||||
content[.content] = pageContent
|
||||
content[.previousPageLinkText] = previousPage.unwrapped { factory.factory.html.makePrevText($0.text) }
|
||||
content[.previousPageUrl] = previousPage?.link
|
||||
content[.nextPageLinkText] = nextPage.unwrapped { factory.factory.html.makeNextText($0.text) }
|
||||
content[.nextPageUrl] = nextPage?.link
|
||||
content[.footer] = page.customFooterContent()
|
||||
|
||||
if pageIncludesCode {
|
||||
let highlightCode = factory.factory.html.codeHighlightFooter()
|
||||
content[.footer] = (content[.footer].unwrapped { $0 + "\n" } ?? "") + highlightCode
|
||||
}
|
||||
|
||||
let url = files.urlInOutputFolder(path)
|
||||
if page.state == .draft {
|
||||
files.isDraft(path: page.path)
|
||||
} else if pageIsEmpty, page.state != .hidden {
|
||||
files.isEmpty(page: path)
|
||||
}
|
||||
guard factory.page.generate(content, to: url) else {
|
||||
return
|
||||
}
|
||||
files.generated(page: path)
|
||||
}
|
||||
|
||||
private func makeContent(page: Element, metadata: Element.LocalizedMetadata, language: String, path: String) -> (content: String, includesCode: Bool, isEmpty: Bool) {
|
||||
let create = configuration.createMdFilesIfMissing
|
||||
if let raw = files.contentOfOptionalFile(atPath: path, source: page.path, createEmptyFileIfMissing: create)?
|
||||
.trimmed.nonEmpty {
|
||||
let (content, includesCode) = PageContentGenerator(factory: factory.factory)
|
||||
.generate(page: page, language: language, content: raw)
|
||||
return (content, includesCode, false)
|
||||
} else {
|
||||
let (content, includesCode) = PageContentGenerator(factory: factory.factory)
|
||||
.generate(page: page, language: language, content: metadata.placeholderText)
|
||||
let placeholder = factory.makePlaceholder(title: metadata.placeholderTitle, text: content)
|
||||
return (placeholder, includesCode, true)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHeader(page: Element, metadata: Element.LocalizedMetadata, language: String) -> String? {
|
||||
let content = factory.makeHeaderContent(page: page, metadata: metadata, language: language)
|
||||
switch page.headerType {
|
||||
case .none:
|
||||
return nil
|
||||
case .left:
|
||||
return factory.factory.leftHeader.generate(content)
|
||||
case .center:
|
||||
return factory.factory.centeredHeader.generate(content)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
47
Sources/Generator/Generators/PageHeadGenerator.swift
Normal file
47
Sources/Generator/Generators/PageHeadGenerator.swift
Normal file
@ -0,0 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
struct PageHeadGenerator {
|
||||
|
||||
static let linkPreviewDesiredImageWidth = 1600
|
||||
|
||||
let factory: TemplateFactory
|
||||
|
||||
init(factory: TemplateFactory) {
|
||||
self.factory = factory
|
||||
}
|
||||
|
||||
func generate(page: Element, language: String, includesCode: Bool = false) -> String {
|
||||
let metadata = page.localized(for: language)
|
||||
|
||||
var content = [PageHeadTemplate.Key : String]()
|
||||
content[.author] = page.author
|
||||
content[.title] = metadata.linkPreviewTitle
|
||||
content[.description] = metadata.linkPreviewDescription
|
||||
if let image = page.linkPreviewImage(for: language) {
|
||||
// Note: Generate separate destination link for the image,
|
||||
// since we don't want a single large image for thumbnails.
|
||||
// Warning: Link preview source path must be relative to root
|
||||
let linkPreviewImageName = image.insert("-link", beforeLast: ".")
|
||||
let sourceImagePath = page.pathRelativeToRootForContainedInputFile(image)
|
||||
let destinationImagePath = page.pathRelativeToRootForContainedInputFile(linkPreviewImageName)
|
||||
files.requireImage(
|
||||
source: sourceImagePath,
|
||||
destination: destinationImagePath,
|
||||
width: PageHeadGenerator.linkPreviewDesiredImageWidth)
|
||||
content[.image] = factory.html.linkPreviewImage(file: linkPreviewImageName)
|
||||
}
|
||||
content[.customPageContent] = page.customHeadContent()
|
||||
if includesCode {
|
||||
let scriptPath = "assets/js/highlight.js"
|
||||
let relative = page.relativePathToOtherSiteElement(file: scriptPath)
|
||||
let includeText = factory.html.scriptInclude(path: relative)
|
||||
if let head = content[.customPageContent] {
|
||||
content[.customPageContent] = head + "\n" + includeText
|
||||
} else {
|
||||
content[.customPageContent] = includeText
|
||||
}
|
||||
}
|
||||
|
||||
return factory.pageHead.generate(content)
|
||||
}
|
||||
}
|
60
Sources/Generator/Generators/SiteGenerator.swift
Normal file
60
Sources/Generator/Generators/SiteGenerator.swift
Normal file
@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
struct SiteGenerator {
|
||||
|
||||
let templates: TemplateFactory
|
||||
|
||||
init() throws {
|
||||
let templatesFolder = files.urlInContentFolder("templates")
|
||||
self.templates = try TemplateFactory(templateFolder: templatesFolder)
|
||||
}
|
||||
|
||||
func generate(site: Element) {
|
||||
site.languages.forEach {
|
||||
generate(site: site, metadata: $0)
|
||||
}
|
||||
}
|
||||
|
||||
private func generate(site: Element, metadata: Element.LocalizedMetadata) {
|
||||
let language = metadata.language
|
||||
let template = LocalizedSiteTemplate(
|
||||
factory: templates,
|
||||
language: language,
|
||||
site: site)
|
||||
|
||||
// Generate sections
|
||||
let overviewGenerator = OverviewPageGenerator(factory: template)
|
||||
let pageGenerator = PageGenerator(factory: template)
|
||||
|
||||
var elementsToProcess: [Element] = [site]
|
||||
while let element = elementsToProcess.popLast() {
|
||||
// Move recursively down to all pages
|
||||
elementsToProcess.append(contentsOf: element.elements)
|
||||
|
||||
processAllFiles(for: element)
|
||||
|
||||
if !element.elements.isEmpty {
|
||||
overviewGenerator.generate(section: element, language: language)
|
||||
} else {
|
||||
#warning("Determine previous and next pages (with relative links)")
|
||||
pageGenerator.generate(
|
||||
page: element,
|
||||
language: language,
|
||||
nextPage: nil,
|
||||
previousPage: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func processAllFiles(for element: Element) {
|
||||
element.requiredFiles.forEach(files.require)
|
||||
element.externalFiles.forEach(files.exclude)
|
||||
element.images.forEach {
|
||||
files.requireImage(
|
||||
source: $0.sourcePath,
|
||||
destination: $0.destinationPath,
|
||||
width: $0.desiredWidth,
|
||||
desiredHeight: $0.desiredHeight)
|
||||
}
|
||||
}
|
||||
}
|
54
Sources/Generator/Generators/ThumbnailListGenerator.swift
Normal file
54
Sources/Generator/Generators/ThumbnailListGenerator.swift
Normal file
@ -0,0 +1,54 @@
|
||||
import Foundation
|
||||
|
||||
struct ThumbnailListGenerator {
|
||||
|
||||
private let factory: TemplateFactory
|
||||
|
||||
init(factory: TemplateFactory) {
|
||||
self.factory = factory
|
||||
}
|
||||
|
||||
func generateContent(items: [Element], parent: Element, language: String, style: ThumbnailStyle) -> String {
|
||||
items.map { itemContent($0, parent: parent, language: language, style: style) }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
private func itemContent(_ item: Element, parent: Element, language: String, style: ThumbnailStyle) -> String {
|
||||
let fullThumbnailPath = item.thumbnailFilePath(for: language)
|
||||
let relativeImageUrl = parent.relativePathToFileWithPath(fullThumbnailPath)
|
||||
let metadata = item.localized(for: language)
|
||||
var content = [ThumbnailKey : String]()
|
||||
|
||||
if item.state.hasThumbnailLink {
|
||||
let fullPageUrl = item.fullPageUrl(for: language)
|
||||
let relativePageUrl = parent.relativePathToFileWithPath(fullPageUrl)
|
||||
content[.url] = "href=\"\(relativePageUrl)\""
|
||||
}
|
||||
|
||||
content[.image] = relativeImageUrl
|
||||
if style == .large, let suffix = metadata.thumbnailSuffix {
|
||||
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
|
||||
} else {
|
||||
content[.title] = metadata.title
|
||||
}
|
||||
content[.image2x] = relativeImageUrl.insert("@2x", beforeLast: ".")
|
||||
content[.corner] = item.cornerText(for: language).unwrapped {
|
||||
factory.largeThumbnail.makeCorner(text: $0)
|
||||
}
|
||||
|
||||
files.requireImage(
|
||||
source: fullThumbnailPath,
|
||||
destination: fullThumbnailPath,
|
||||
width: style.width,
|
||||
desiredHeight: style.height)
|
||||
|
||||
// Create image version for high-resolution screens
|
||||
files.requireImage(
|
||||
source: fullThumbnailPath,
|
||||
destination: fullThumbnailPath.insert("@2x", beforeLast: "."),
|
||||
width: style.width * 2,
|
||||
desiredHeight: style.height * 2)
|
||||
|
||||
return factory.thumbnail(style: style).generate(content, shouldIndent: false)
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
struct BackNavigationTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case url = "URL"
|
||||
case text = "TEXT"
|
||||
}
|
||||
|
||||
static let templateName = "back.html"
|
||||
|
||||
let raw: String
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct OverviewSectionCleanTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case items = "ITEMS"
|
||||
}
|
||||
|
||||
static let templateName = "overview-section-clean.html"
|
||||
|
||||
let raw: String
|
||||
}
|
@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
struct OverviewSectionTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case url = "URL"
|
||||
case title = "TITLE"
|
||||
case items = "ITEMS"
|
||||
case more = "MORE"
|
||||
}
|
||||
|
||||
static let templateName = "overview-section.html"
|
||||
|
||||
let raw: String
|
||||
}
|
16
Sources/Generator/Templates/Elements/PageHeadTemplate.swift
Normal file
16
Sources/Generator/Templates/Elements/PageHeadTemplate.swift
Normal file
@ -0,0 +1,16 @@
|
||||
import Foundation
|
||||
|
||||
struct PageHeadTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case author = "AUTHOR"
|
||||
case title = "TITLE"
|
||||
case description = "DESCRIPTION"
|
||||
case image = "IMAGE"
|
||||
case customPageContent = "CUSTOM"
|
||||
}
|
||||
|
||||
let raw: String
|
||||
|
||||
static let templateName = "head.html"
|
||||
}
|
18
Sources/Generator/Templates/Elements/PageImageTemplate.swift
Normal file
18
Sources/Generator/Templates/Elements/PageImageTemplate.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct PageImageTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case image = "IMAGE"
|
||||
case image2x = "IMAGE_2X"
|
||||
case width = "WIDTH"
|
||||
case height = "HEIGHT"
|
||||
case leftText = "LEFT_TEXT"
|
||||
case rightText = "RIGHT_TEXT"
|
||||
}
|
||||
|
||||
static let templateName = "image.html"
|
||||
|
||||
let raw: String
|
||||
|
||||
}
|
37
Sources/Generator/Templates/Elements/PageVideoTemplate.swift
Normal file
37
Sources/Generator/Templates/Elements/PageVideoTemplate.swift
Normal file
@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
struct PageVideoTemplate: Template {
|
||||
|
||||
typealias VideoSource = (url: String, type: VideoType)
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case options = "OPTIONS"
|
||||
case sources = "SOURCES"
|
||||
}
|
||||
|
||||
enum VideoOption: String {
|
||||
case controls
|
||||
case autoplay
|
||||
case muted
|
||||
case loop
|
||||
case playsinline
|
||||
case poster
|
||||
case preload
|
||||
}
|
||||
|
||||
static let templateName = "video.html"
|
||||
|
||||
let raw: String
|
||||
|
||||
func generate<T>(sources: [VideoSource], options: T) -> String where T: Sequence, T.Element == VideoOption {
|
||||
let sourcesCode = sources.map(makeSource).joined(separator: "\n")
|
||||
let optionCode = options.map { $0.rawValue }.joined(separator: " ")
|
||||
return generate([.sources: sourcesCode, .options: optionCode])
|
||||
}
|
||||
|
||||
private func makeSource(_ source: VideoSource) -> String {
|
||||
"""
|
||||
<source src="\(source.url)" type="\(source.type.htmlType)">
|
||||
"""
|
||||
}
|
||||
}
|
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
struct PlaceholderTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case title = "TITLE"
|
||||
case text = "TEXT"
|
||||
}
|
||||
|
||||
static let templateName = "empty.html"
|
||||
|
||||
var raw: String
|
||||
}
|
50
Sources/Generator/Templates/Elements/ThumbnailTemplate.swift
Normal file
50
Sources/Generator/Templates/Elements/ThumbnailTemplate.swift
Normal file
@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
protocol ThumbnailTemplate {
|
||||
|
||||
func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) -> String
|
||||
}
|
||||
|
||||
enum ThumbnailKey: String, CaseIterable {
|
||||
case url = "URL"
|
||||
case image = "IMAGE"
|
||||
case image2x = "IMAGE_2X"
|
||||
case title = "TITLE"
|
||||
case corner = "CORNER"
|
||||
}
|
||||
|
||||
struct LargeThumbnailTemplate: Template, ThumbnailTemplate {
|
||||
|
||||
typealias Key = ThumbnailKey
|
||||
|
||||
static let templateName = "thumbnail-large.html"
|
||||
|
||||
let raw: String
|
||||
|
||||
func makeCorner(text: String) -> String {
|
||||
"<span class=\"corner\"><span>\(text)</span></span>"
|
||||
}
|
||||
|
||||
func makeTitleSuffix(_ suffix: String) -> String {
|
||||
"<span class=\"suffix\">\(suffix)</span>"
|
||||
}
|
||||
}
|
||||
|
||||
struct SquareThumbnailTemplate: Template, ThumbnailTemplate {
|
||||
|
||||
typealias Key = ThumbnailKey
|
||||
|
||||
static let templateName = "thumbnail-square.html"
|
||||
|
||||
let raw: String
|
||||
}
|
||||
|
||||
struct SmallThumbnailTemplate: Template, ThumbnailTemplate {
|
||||
|
||||
typealias Key = ThumbnailKey
|
||||
|
||||
static let templateName = "thumbnail-small.html"
|
||||
|
||||
let raw: String
|
||||
}
|
||||
|
15
Sources/Generator/Templates/Elements/TopBarTemplate.swift
Normal file
15
Sources/Generator/Templates/Elements/TopBarTemplate.swift
Normal file
@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
struct TopBarTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case title = "TITLE"
|
||||
case titleLink = "TITLE_URL"
|
||||
case elements = "ELEMENTS"
|
||||
case languageButton = "LANG_BUTTON"
|
||||
}
|
||||
|
||||
static let templateName = "bar.html"
|
||||
|
||||
var raw: String
|
||||
}
|
158
Sources/Generator/Templates/Filled/LocalizedSiteTemplate.swift
Normal file
158
Sources/Generator/Templates/Filled/LocalizedSiteTemplate.swift
Normal file
@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
import Ink
|
||||
|
||||
struct LocalizedSiteTemplate {
|
||||
|
||||
let author: String
|
||||
|
||||
let factory: TemplateFactory
|
||||
|
||||
let topBar: PrefilledTopBarTemplate
|
||||
|
||||
// MARK: Site Elements
|
||||
|
||||
var backNavigation: BackNavigationTemplate {
|
||||
factory.backNavigation
|
||||
}
|
||||
|
||||
let pageHead: PageHeadGenerator
|
||||
|
||||
let overviewSection: OverviewSectionGenerator
|
||||
|
||||
private let fullDateFormatter: DateFormatter
|
||||
|
||||
private let month: DateFormatter
|
||||
|
||||
private let day: DateFormatter
|
||||
|
||||
var language: String {
|
||||
topBar.language
|
||||
}
|
||||
|
||||
// MARK: Pages
|
||||
|
||||
var page: PageTemplate {
|
||||
factory.page
|
||||
}
|
||||
|
||||
init(factory: TemplateFactory, language: String, site: Element) {
|
||||
self.author = site.author
|
||||
self.factory = factory
|
||||
|
||||
let df = DateFormatter()
|
||||
df.dateStyle = .long
|
||||
df.timeStyle = .none
|
||||
df.locale = Locale(identifier: language)
|
||||
self.fullDateFormatter = df
|
||||
|
||||
let df2 = DateFormatter()
|
||||
df2.dateFormat = "MMMM"
|
||||
df2.locale = Locale(identifier: language)
|
||||
self.month = df2
|
||||
|
||||
let df3 = DateFormatter()
|
||||
df3.dateFormat = "dd"
|
||||
df3.locale = Locale(identifier: language)
|
||||
self.day = df3
|
||||
|
||||
let sections = site.sortedItems.map {
|
||||
PrefilledTopBarTemplate.SectionInfo(
|
||||
name: $0.title(for: language),
|
||||
url: $0.path + Element.htmlPagePathAddition(for: language))
|
||||
}
|
||||
|
||||
self.topBar = .init(
|
||||
factory: factory,
|
||||
language: language,
|
||||
sections: sections,
|
||||
topBarWebsiteTitle: site.topBarTitle)
|
||||
self.pageHead = PageHeadGenerator(
|
||||
factory: factory)
|
||||
self.overviewSection = OverviewSectionGenerator(
|
||||
factory: factory)
|
||||
}
|
||||
|
||||
// MARK: Content
|
||||
|
||||
func makePlaceholder(metadata: Element.LocalizedMetadata) -> String {
|
||||
makePlaceholder(title: metadata.placeholderTitle, text: metadata.placeholderText)
|
||||
}
|
||||
|
||||
func makePlaceholder(title: String, text: String) -> String {
|
||||
factory.placeholder.generate([
|
||||
.title: title,
|
||||
.text: text])
|
||||
}
|
||||
|
||||
func makeBackLink(text: String, language: String) -> String {
|
||||
let content: [BackNavigationTemplate.Key : String] = [
|
||||
.text: text,
|
||||
.url: ".." + Element.htmlPagePathAddition(for: language)
|
||||
]
|
||||
return backNavigation.generate(content)
|
||||
}
|
||||
|
||||
func makeDateString(start: Date?, end: Date?) -> String {
|
||||
guard let start = start else {
|
||||
return ""
|
||||
}
|
||||
guard let end = end else {
|
||||
return fullDateFormatter.string(from: start)
|
||||
}
|
||||
|
||||
switch language {
|
||||
case "de":
|
||||
return makeGermanDateString(start: start, end: end)
|
||||
case "en":
|
||||
fallthrough
|
||||
default:
|
||||
return makeEnglishDateString(start: start, end: end)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeGermanDateString(start: Date, end: Date) -> String {
|
||||
guard Calendar.current.isDate(start, equalTo: end, toGranularity: .year) else {
|
||||
return "\(fullDateFormatter.string(from: start)) - \(fullDateFormatter.string(from: end))"
|
||||
}
|
||||
|
||||
let startDay = day.string(from: start)
|
||||
guard Calendar.current.isDate(start, equalTo: end, toGranularity: .month) else {
|
||||
let startMonth = month.string(from: start)
|
||||
return "\(startDay). \(startMonth) - \(fullDateFormatter.string(from: end))"
|
||||
}
|
||||
return "\(startDay). - \(fullDateFormatter.string(from: end))"
|
||||
}
|
||||
|
||||
private func makeEnglishDateString(start: Date, end: Date) -> String {
|
||||
guard Calendar.current.isDate(start, equalTo: end, toGranularity: .year) else {
|
||||
return "\(fullDateFormatter.string(from: start)) - \(fullDateFormatter.string(from: end))"
|
||||
}
|
||||
|
||||
guard Calendar.current.isDate(start, equalTo: end, toGranularity: .month) else {
|
||||
let startDay = day.string(from: start)
|
||||
let startMonth = month.string(from: start)
|
||||
return "\(startMonth) \(startDay) - \(fullDateFormatter.string(from: end))"
|
||||
}
|
||||
return fullDateFormatter.string(from: start)
|
||||
.insert(" - \(day.string(from: end))", beforeLast: ",")
|
||||
}
|
||||
|
||||
func makeHeaderContent(page: Element, metadata: Element.LocalizedMetadata, language: String) -> [HeaderKey : String] {
|
||||
let backText = page.backLinkText(for: language)
|
||||
|
||||
var content = [HeaderKey : String]()
|
||||
if let backText = backText.nonEmpty {
|
||||
content[.backLink] = makeBackLink(text: backText, language: language)
|
||||
}
|
||||
if let suffix = metadata.titleSuffix {
|
||||
content[.title] = factory.html.make(title: metadata.title, suffix: suffix)
|
||||
} else {
|
||||
content[.title] = metadata.title
|
||||
}
|
||||
content[.subtitle] = metadata.subtitle
|
||||
content[.titleText] = metadata.description
|
||||
content[.date] = makeDateString(start: page.date, end: page.endDate)
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
struct PrefilledTopBarTemplate {
|
||||
|
||||
let language: String
|
||||
|
||||
let sections: [SectionInfo]
|
||||
|
||||
let topBarWebsiteTitle: String
|
||||
|
||||
private let factory: TemplateFactory
|
||||
|
||||
init(factory: TemplateFactory, language: String, sections: [SectionInfo], topBarWebsiteTitle: String) {
|
||||
self.factory = factory
|
||||
self.language = language
|
||||
self.sections = sections
|
||||
self.topBarWebsiteTitle = topBarWebsiteTitle
|
||||
}
|
||||
|
||||
func generate(sectionUrl: String?, languageButton: String?, page: Element) -> String {
|
||||
var content = [TopBarTemplate.Key : String]()
|
||||
content[.title] = topBarWebsiteTitle
|
||||
content[.titleLink] = factory.html.topBarWebsiteTitle(language: language, from: page)
|
||||
content[.elements] = elements(activeSectionUrl: sectionUrl)
|
||||
content[.languageButton] = languageButton.unwrapped(factory.html.topBarLanguageButton) ?? ""
|
||||
return factory.topBar.generate(content)
|
||||
}
|
||||
|
||||
private func elements(activeSectionUrl: String?) -> String {
|
||||
sections
|
||||
.map { factory.html.topBarNavigationLink(url: $0.url, text: $0.name, isActive: activeSectionUrl == $0.url) }
|
||||
.joined(separator: "\n")
|
||||
}
|
||||
|
||||
struct SectionInfo {
|
||||
|
||||
let name: String
|
||||
|
||||
let url: String
|
||||
}
|
||||
}
|
32
Sources/Generator/Templates/Pages/HeaderTemplate.swift
Normal file
32
Sources/Generator/Templates/Pages/HeaderTemplate.swift
Normal file
@ -0,0 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
protocol HeaderTemplate {
|
||||
|
||||
func generate(_ content: [ThumbnailKey : String], shouldIndent: Bool) throws -> String
|
||||
}
|
||||
|
||||
enum HeaderKey: String, CaseIterable {
|
||||
case backLink = "BACK_LINK"
|
||||
case title = "TITLE"
|
||||
case subtitle = "SUBTITLE"
|
||||
case titleText = "TITLE_TEXT"
|
||||
case date = "DATE"
|
||||
}
|
||||
|
||||
struct CenteredHeaderTemplate: Template {
|
||||
|
||||
typealias Key = HeaderKey
|
||||
|
||||
let raw: String
|
||||
|
||||
static let templateName = "header-center.html"
|
||||
}
|
||||
|
||||
struct LeftHeaderTemplate: Template {
|
||||
|
||||
typealias Key = HeaderKey
|
||||
|
||||
let raw: String
|
||||
|
||||
static let templateName = "header-left.html"
|
||||
}
|
21
Sources/Generator/Templates/Pages/PageTemplate.swift
Normal file
21
Sources/Generator/Templates/Pages/PageTemplate.swift
Normal file
@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
|
||||
struct PageTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case head = "HEAD"
|
||||
case topBar = "TOP_BAR"
|
||||
case contentClass = "CONTENT_CLASS"
|
||||
case header = "HEADER"
|
||||
case content = "CONTENT"
|
||||
case previousPageLinkText = "PREV_TEXT"
|
||||
case previousPageUrl = "PREV_LINK"
|
||||
case nextPageLinkText = "NEXT_TEXT"
|
||||
case nextPageUrl = "NEXT_LINK"
|
||||
case footer = "FOOTER"
|
||||
}
|
||||
|
||||
static let templateName = "page.html"
|
||||
|
||||
let raw: String
|
||||
}
|
58
Sources/Generator/Templates/Template.swift
Normal file
58
Sources/Generator/Templates/Template.swift
Normal file
@ -0,0 +1,58 @@
|
||||
import Foundation
|
||||
|
||||
protocol Template {
|
||||
|
||||
associatedtype Key where Key: RawRepresentable, Key.RawValue == String, Key: CaseIterable, Key: Hashable
|
||||
|
||||
static var templateName: String { get }
|
||||
|
||||
var raw: String { get }
|
||||
|
||||
init(raw: String)
|
||||
|
||||
}
|
||||
|
||||
extension Template {
|
||||
|
||||
init(in folder: URL) throws {
|
||||
let url = folder.appendingPathComponent(Self.templateName)
|
||||
try self.init(from: url)
|
||||
}
|
||||
|
||||
init(from url: URL) throws {
|
||||
let raw = try String(contentsOf: url)
|
||||
self.init(raw: raw)
|
||||
}
|
||||
|
||||
func generate(_ content: [Key : String], to url: URL) -> Bool {
|
||||
let content = generate(content)
|
||||
return files.write(content, to: url)
|
||||
}
|
||||
|
||||
func generate(_ content: [Key : String], shouldIndent: Bool = false) -> String {
|
||||
var result = raw.components(separatedBy: "\n")
|
||||
|
||||
Key.allCases.forEach { key in
|
||||
let newContent = content[key]?.withoutEmptyLines ?? ""
|
||||
let stringMarker = "<!--\(key.rawValue)-->"
|
||||
let indices = result.enumerated().filter { $0.element.contains(stringMarker) }
|
||||
.map { $0.offset }
|
||||
guard !indices.isEmpty else {
|
||||
return
|
||||
}
|
||||
for index in indices {
|
||||
let old = result[index].components(separatedBy: stringMarker)
|
||||
// Add indentation to all added lines
|
||||
let indentation = old.first!
|
||||
guard shouldIndent, indentation.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||
// Prefix is not indentation, so just insert new content
|
||||
result[index] = old.joined(separator: newContent)
|
||||
continue
|
||||
}
|
||||
let indentedReplacements = newContent.indented(by: indentation)
|
||||
result[index] = old.joined(separator: indentedReplacements)
|
||||
}
|
||||
}
|
||||
return result.joined(separator: "\n").withoutEmptyLines
|
||||
}
|
||||
}
|
78
Sources/Generator/Templates/TemplateFactory.swift
Normal file
78
Sources/Generator/Templates/TemplateFactory.swift
Normal file
@ -0,0 +1,78 @@
|
||||
import Foundation
|
||||
|
||||
final class TemplateFactory {
|
||||
|
||||
let templateFolder: URL
|
||||
|
||||
// MARK: Site Elements
|
||||
|
||||
let backNavigation: BackNavigationTemplate
|
||||
|
||||
let pageHead: PageHeadTemplate
|
||||
|
||||
let topBar: TopBarTemplate
|
||||
|
||||
let overviewSection: OverviewSectionTemplate
|
||||
|
||||
let overviewSectionClean: OverviewSectionCleanTemplate
|
||||
|
||||
let placeholder: PlaceholderTemplate
|
||||
|
||||
// MARK: Thumbnails
|
||||
|
||||
let largeThumbnail: LargeThumbnailTemplate
|
||||
|
||||
let squareThumbnail: SquareThumbnailTemplate
|
||||
|
||||
let smallThumbnail: SmallThumbnailTemplate
|
||||
|
||||
func thumbnail(style: ThumbnailStyle) -> ThumbnailTemplate {
|
||||
switch style {
|
||||
case .large:
|
||||
return largeThumbnail
|
||||
case .square:
|
||||
return squareThumbnail
|
||||
case .small:
|
||||
return smallThumbnail
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Headers
|
||||
|
||||
let leftHeader: LeftHeaderTemplate
|
||||
|
||||
let centeredHeader: CenteredHeaderTemplate
|
||||
|
||||
// MARK: Pages
|
||||
|
||||
let page: PageTemplate
|
||||
|
||||
let image: PageImageTemplate
|
||||
|
||||
let video: PageVideoTemplate
|
||||
|
||||
// MARK: HTML
|
||||
|
||||
let html: HTMLElementsGenerator
|
||||
|
||||
// MARK: Init
|
||||
|
||||
init(templateFolder: URL) throws {
|
||||
self.templateFolder = templateFolder
|
||||
self.backNavigation = try .init(in: templateFolder)
|
||||
self.pageHead = try .init(in: templateFolder)
|
||||
self.topBar = try .init(in: templateFolder)
|
||||
self.overviewSection = try .init(in: templateFolder)
|
||||
self.overviewSectionClean = try .init(in: templateFolder)
|
||||
self.placeholder = try .init(in: templateFolder)
|
||||
self.largeThumbnail = try .init(in: templateFolder)
|
||||
self.squareThumbnail = try .init(in: templateFolder)
|
||||
self.smallThumbnail = try .init(in: templateFolder)
|
||||
self.leftHeader = try .init(in: templateFolder)
|
||||
self.centeredHeader = try .init(in: templateFolder)
|
||||
self.page = try .init(in: templateFolder)
|
||||
self.image = try .init(in: templateFolder)
|
||||
self.video = try .init(in: templateFolder)
|
||||
self.html = .init()
|
||||
}
|
||||
}
|
50
Sources/Generator/main.swift
Normal file
50
Sources/Generator/main.swift
Normal file
@ -0,0 +1,50 @@
|
||||
import Foundation
|
||||
|
||||
#warning("TODO: Add markdown box command: ")
|
||||
#warning("TODO: Add pretty link to other page in page content: ")
|
||||
#warning("TODO: Improve display of processed image list and warnings")
|
||||
|
||||
let args = CommandLine.arguments
|
||||
|
||||
guard args.count == 2 else {
|
||||
print("Invalid argument list")
|
||||
print("Usage: generator config-path")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let configuration: Configuration
|
||||
do {
|
||||
let configUrl = URL(fileURLWithPath: args[1])
|
||||
let data = try Data(contentsOf: configUrl)
|
||||
configuration = try JSONDecoder().decode(from: data)
|
||||
} catch {
|
||||
print("Failed to read configuration: \(error)")
|
||||
exit(1)
|
||||
}
|
||||
|
||||
let log = ValidationLog()
|
||||
let files = FileSystem(
|
||||
in: configuration.contentDirectory,
|
||||
to: configuration.outputDirectory)
|
||||
|
||||
guard let siteData = Element(atRoot: configuration.contentDirectory) else {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
do {
|
||||
let siteGenerator = try SiteGenerator()
|
||||
siteGenerator.generate(site: siteData)
|
||||
} catch {
|
||||
print("Failed to generate website: \(error)")
|
||||
exit(2)
|
||||
}
|
||||
|
||||
files.printGeneratedPages()
|
||||
files.printEmptyPages()
|
||||
files.printDraftPages()
|
||||
|
||||
files.createImages()
|
||||
print("Images generated")
|
||||
files.copyRequiredFiles()
|
||||
files.printExternalFiles()
|
||||
files.writeHashes()
|
Reference in New Issue
Block a user