First version

This commit is contained in:
Christoph Hagen
2022-08-16 10:39:05 +02:00
parent 104c5151b4
commit 14b935249f
44 changed files with 2891 additions and 8 deletions

View File

@@ -0,0 +1,119 @@
import Foundation
protocol LanguageIdentifiable {
var languageIdentifier: String { get }
var title: String { get }
}
protocol LanguageContainer {
associatedtype LocalizedContainer: LanguageIdentifiable
var languages: [LocalizedContainer] { get }
}
protocol LocalizedMetadataContainer {
associatedtype MetadataType: LanguageContainer
var metadata: MetadataType { get }
func hasContent(for language: String) -> Bool
}
// MARK: Default implementations
extension LocalizedMetadataContainer {
func hasContent(for language: String) -> Bool {
true
}
}
// MARK: Extensions
extension LocalizedMetadataContainer {
func localized(for language: String) -> MetadataType.LocalizedContainer {
metadata.localized(for: language)
}
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String {
localized(for: language).title
}
func nextLanguage(for languageIdentifier: String) -> String? {
let langs = metadata.languages.map { $0.languageIdentifier }
guard let index = langs.firstIndex(of: languageIdentifier) else {
return nil
}
for i in 1..<langs.count {
let next = langs[(index + i) % langs.count]
guard hasContent(for: next) else {
continue
}
guard next != languageIdentifier else {
return nil
}
return next
}
return nil
}
}
extension LanguageContainer {
var languageIdentifiers: [String] {
languages.map { $0.languageIdentifier }
}
#warning("Throw better error for missing language")
func localized(for language: String) -> LocalizedContainer {
languages.first { $0.languageIdentifier == language }!
}
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String {
localized(for: language).title
}
}
extension LocalizedMetadataContainer where Self: SiteElement, Self.MetadataType.LocalizedContainer: LinkPreviewMetadataProvider {
private func linkPreviewImageFileName(for language: String) -> String? {
if let fileName = localized(for: language).linkPreview?.image {
return fileName
}
// Check for the existence of a localized thumbnail
let fileName = Self.thumbnailFileNameLocalized(for: language)
if inputFolder.appendingPathComponent(fileName).exists {
return fileName
}
let defaultThumbnail = Self.defaultThumbnailFileName
if inputFolder.appendingPathComponent(defaultThumbnail).exists {
return defaultThumbnail
}
return nil
}
func linkPreviewImage(for language: String) -> String? {
guard let fileName = linkPreviewImageFileName(for: language) else {
return nil
}
return "/\(path)/\(fileName)"
}
}

View File

@@ -0,0 +1,41 @@
import Foundation
/**
Localized configuration data for link previews of site elements.
This struct is embedded in localized metadata and intended to be filled in the JSON source.
*/
struct LinkPreviewMetadata {
/**
The title to use for the link preview.
If `nil` is specified, then the localized element title is used.
*/
let title: 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 image: 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 description: String?
}
extension LinkPreviewMetadata: Codable { }
extension LinkPreviewMetadata {
static var initial: LinkPreviewMetadata {
.init(title: nil,
image: nil,
description: "The page description for link previews")
}
}

View File

@@ -0,0 +1,23 @@
import Foundation
protocol LinkPreviewMetadataProvider {
var linkPreview: LinkPreviewMetadata? { get }
var title: String { get }
var subtitle: String? { get }
var description: String { get }
}
extension LinkPreviewMetadataProvider {
var linkPreviewTitle: String {
linkPreview?.title ?? title
}
var linkPreviewDescription: String {
linkPreview?.description ?? subtitle ?? description
}
}

View File

@@ -0,0 +1,29 @@
import Foundation
protocol Metadata: Codable {
static var fileName: String { get }
static var initial: Self { get }
}
extension Metadata {
static func url(in folder: URL) -> URL {
folder.appendingPathComponent(fileName)
}
static func exists(in folder: URL) -> Bool {
url(in: folder).exists
}
init?(in folder: URL) throws {
let metadataUrl = Self.url(in: folder)
guard metadataUrl.exists else {
try Self.initial.writeJSON(to: metadataUrl)
print("Created metadata in \(folder)")
return nil
}
try self.init(decodeFrom: metadataUrl)
}
}

View File

@@ -0,0 +1,62 @@
import Foundation
extension Page {
struct LocalizedMetadata {
let id: String
let title: String
#warning("Generate title suffix")
let titleSuffix: String?
let linkPreview: LinkPreviewMetadata?
let subtitle: String?
#warning("Generate thumbnail suffix")
let thumbnailSuffix: String?
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 Page.LocalizedMetadata: Codable {
}
extension Page.LocalizedMetadata: LanguageIdentifiable {
var languageIdentifier: String {
id
}
}
extension Page.LocalizedMetadata {
static var initial: Page.LocalizedMetadata {
.init(id: "en",
title: "Page title",
titleSuffix: nil,
linkPreview: .initial,
subtitle: "Some text below the title",
thumbnailSuffix: "Project",
cornerText: nil,
externalUrl: nil)
}
}
extension Page.LocalizedMetadata: LinkPreviewMetadataProvider {
var description: String { subtitle ?? title }
}

View File

@@ -0,0 +1,93 @@
import Foundation
extension Page {
struct Metadata {
let date: Date
let endDate: Date?
let author: String?
let isDraft: Bool
let sortIndex: Int?
let languages: [LocalizedMetadata]
#warning("Add hideFromOverview property")
let requiredFiles: [String]
#warning("Add files for which errors are ignored when missing")
}
}
extension Page.Metadata: Metadata {
static let fileName = "page.json"
static var initial: Page.Metadata {
.init(
date: .now,
endDate: .now,
author: nil,
isDraft: true,
sortIndex: 0,
languages: [.initial],
requiredFiles: [])
}
}
extension Page.Metadata: LanguageContainer {
}
extension Page.Metadata: Codable {
enum CodingKeys: CodingKey {
case date
case endDate
case author
case isDraft
case sortIndex
case languages
case requiredFiles
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
let dateString = Page.metadataDateFormatter.string(from: date)
try container.encode(dateString, forKey: .date)
if let date = endDate {
let endDateString = Page.metadataDateFormatter.string(from: date)
try container.encode(endDateString, forKey: .endDate)
}
try container.encodeIfPresent(author, forKey: .author)
try container.encode(isDraft, forKey: .isDraft)
try container.encodeIfPresent(sortIndex, forKey: .sortIndex)
try container.encode(languages, forKey: .languages)
if !requiredFiles.isEmpty {
try container.encode(requiredFiles, forKey: .requiredFiles)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let dateString = try container.decode(String.self, forKey: .date)
self.date = try Page.metadataDateFormatter.date(from: dateString)
.unwrap(or: .invalidDateInPageMetadata(dateString))
self.author = try container.decodeIfPresent(String.self, forKey: .author)
self.languages = try container.decode([Page.LocalizedMetadata].self, forKey: .languages)
self.isDraft = try container.decodeIfPresent(Bool.self, forKey: .isDraft) ?? false
self.sortIndex = try container.decodeIfPresent(Int.self, forKey: .sortIndex)
if let endDateString = try container.decodeIfPresent(String.self, forKey: .endDate) {
self.endDate = try Page.metadataDateFormatter.date(from: endDateString)
.unwrap(or: .invalidDateInPageMetadata(endDateString))
} else {
self.endDate = nil
}
self.requiredFiles = try container.decodeIfPresent([String].self, forKey: .requiredFiles) ?? []
}
}

View File

@@ -0,0 +1,83 @@
import Foundation
struct Page {
let metadata: Metadata
/// The input folder where the page data is stored
let inputFolder: URL
let path: String
init?(folder: URL, path: String) throws {
self.path = path
guard let metadata = try Metadata(in: folder) else {
return nil
}
self.inputFolder = folder
self.metadata = metadata
}
}
extension Page {
static let metadataDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "dd.MM.yy"
return df
}()
}
extension Page: SiteElement {
var sortIndex: Int? {
metadata.sortIndex
}
var sortDate: Date? {
metadata.date
}
var elements: [SiteElement] { [] }
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
var isExternalPage: Bool {
metadata.languages.contains { $0.externalUrl != nil }
}
func fullPageUrl(for language: String) -> String {
localized(for: language).externalUrl ?? "\(path)/\(language).html"
}
}
extension Page: LocalizedMetadataContainer {
/**
Get the url of the content markdown file for a language.
To check if the file also exists, use `existingContentUrl(for:)`
*/
func contentUrl(for language: String) -> URL {
inputFolder.appendingPathComponent("\(language).md")
}
/**
Get the url of existing markdown content for a language.
*/
func existingContentUrl(for language: String) -> URL? {
let url = contentUrl(for: language)
guard url.exists else {
return nil
}
return url
}
func hasContent(for language: String) -> Bool {
existingContentUrl(for: language) != nil
}
}

View File

@@ -0,0 +1,65 @@
import Foundation
extension Section {
struct LocalizedMetadata {
let id: String
let title: String
let subtitle: String?
let description: String
/**
The text on the link to show the section page when previewing multiple sections on an overview page.
*/
let moreLinkTitle: String
/**
An optional text to display in the corner of the section thumbnail.
Can be used to show things like "new", "draft", etc.
*/
let cornerText: String?
let linkPreview: LinkPreviewMetadata?
/**
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?
}
}
extension Section.LocalizedMetadata: Codable {
}
extension Section.LocalizedMetadata: LanguageIdentifiable {
var languageIdentifier: String {
id
}
}
extension Section.LocalizedMetadata {
static var initial: Section.LocalizedMetadata {
.init(id: "en",
title: "Section title",
subtitle: "Tag line below the title",
description: "The short text below the tagline on the section overview page",
moreLinkTitle: "More section items",
cornerText: nil,
linkPreview: .initial,
backLinkText: "Back to section")
}
}
extension Section.LocalizedMetadata: LinkPreviewMetadataProvider {
}

View File

@@ -0,0 +1,79 @@
import Foundation
extension Section {
static let defaultSectionOverviewItemCount = 6
struct Metadata {
let thumbnailStyle: ThumbnailStyle
let sortByMostRecent: Bool
let sortIndex: Int?
let date: Date?
let languages: [LocalizedMetadata]
let sectionOverviewItemCount: Int
}
}
extension Section.Metadata: Metadata {
static let fileName = "section.json"
static var initial: Section.Metadata {
.init(thumbnailStyle: .large,
sortByMostRecent: true,
sortIndex: nil,
date: nil,
languages: [.initial],
sectionOverviewItemCount: 6)
}
}
extension Section.Metadata: LanguageContainer {
}
extension Section.Metadata: Codable {
enum CodingKeys: CodingKey {
case thumbnailStyle
case sortByMostRecent
case sortIndex
case date
case languages
case sectionOverviewItemCount
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(thumbnailStyle, forKey: .thumbnailStyle)
try container.encode(sortByMostRecent, forKey: .sortByMostRecent)
try container.encodeIfPresent(sortIndex, forKey: .sortIndex)
try container.encode(languages, forKey: .languages)
if let date = date {
let dateString = Page.metadataDateFormatter.string(from: date)
try container.encode(dateString, forKey: .date)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.thumbnailStyle = try container.decode(ThumbnailStyle.self, forKey: .thumbnailStyle)
self.sortByMostRecent = try container.decode(Bool.self, forKey: .sortByMostRecent)
self.sortIndex = try container.decodeIfPresent(Int.self, forKey: .sortIndex)
self.languages = try container.decode([Section.LocalizedMetadata].self, forKey: .languages)
if let dateString = try container.decodeIfPresent(String.self, forKey: .date) {
self.date = try Page.metadataDateFormatter.date(from: dateString)
.unwrap(or: .invalidDateInPageMetadata(dateString))
} else {
self.date = nil
}
self.sectionOverviewItemCount = try container
.decodeIfPresent(Int.self, forKey: .sectionOverviewItemCount) ?? Section.defaultSectionOverviewItemCount
}
}

View File

@@ -0,0 +1,74 @@
import Foundation
struct Section {
let metadata: Metadata
let inputFolder: URL
let elements: [SiteElement]
/// The path to get to the section from the root folder (no leading slash)
let path: String
var folderName: String {
inputFolder.lastPathComponent
}
var sortedItems: [SiteElement] {
guard metadata.sortByMostRecent else {
return elements.sorted { $0.sortIndex! < $1.sortIndex! }
}
return elements.sorted { $0.sortDate! > $1.sortDate! }
}
init?(folder: URL, path: String) throws {
self.path = path
guard let metadata = try Metadata(in: folder) else {
return nil
}
self.metadata = metadata
self.inputFolder = folder
let elements: [SiteElement] = try FileSystem.folders(in: folder)
.compactMap {
let sectionPath = "\(path)/\($0.lastPathComponent)"
if Page.Metadata.exists(in: $0) {
return try Page(folder: $0, path: sectionPath)
}
if Section.Metadata.exists(in: $0) {
return try Section(folder: $0, path: sectionPath)
}
return nil
}
if metadata.sortByMostRecent {
self.elements = elements.sorted { $0.sortDate! > $1.sortDate! }
} else {
self.elements = elements.sorted { $0.sortIndex! < $1.sortIndex! }
}
#warning("Verify that all sort indices or sort dates are present")
print("Section \(folderName): \(elements.count) pages")
}
}
extension Section: SiteElement {
var sortIndex: Int? {
metadata.sortIndex
}
var sortDate: Date? {
metadata.date
}
func cornerText(for language: String) -> String? {
localized(for: language).cornerText
}
func backLinkText(for language: String) -> String? {
localized(for: language).backLinkText
}
}
extension Section: LocalizedMetadataContainer {
}

View File

@@ -0,0 +1,71 @@
import Foundation
extension Site {
struct LocalizedMetadata {
let languageIdentifier: String
let linkPreview: LinkPreviewMetadata?
let title: String
let subtitle: String?
let description: 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 back text to use for element which don't specify a `backLinkText` themselves.
*/
let defaultBackLinkText: String
/**
The text to show as a title for placeholder boxes
Placeholders are included in missing pages.
*/
let placeholderTitle: String
/**
The text to show as a description for placeholder boxes
Placeholders are included in missing pages.
*/
let placeholderText: String
}
}
extension Site.LocalizedMetadata: Codable {
}
extension Site.LocalizedMetadata: LanguageIdentifiable {
}
extension Site.LocalizedMetadata {
static var initial: Site.LocalizedMetadata {
.init(
languageIdentifier: "en",
linkPreview: .initial,
title: "Website name on front page",
subtitle: "Tag line on front page",
description: "Some text below the tag line on the title page",
backLinkText: "Back to start",
defaultBackLinkText: "Back",
placeholderTitle: "Content missing",
placeholderText: "This page is incomplete. Content will be added in the coming days.")
}
}
extension Site.LocalizedMetadata: LinkPreviewMetadataProvider {
}

View File

@@ -0,0 +1,48 @@
import Foundation
extension Site {
struct Metadata {
let author: String
let ignoredSubFolders: Set<String>
let topBarTitle: String?
/**
The url where the site will be deployed.
This value is required to build absolute links for link previews.
- Note: The path does not need to contain a trailing slash.
*/
let deployedBaseUrl: String
let languages: [LocalizedMetadata]
static func write(to url: URL) throws {
try Metadata.initial.writeJSON(to: url)
}
}
}
extension Site.Metadata: LanguageContainer {
}
extension Site.Metadata: Codable {
}
extension Site.Metadata: Metadata {
static let fileName = "site.json"
static var initial: Self {
.init(author: "Author",
ignoredSubFolders: ["templates"],
topBarTitle: "<b>Title</b>",
deployedBaseUrl: "http://example.com",
languages: [.initial])
}
}

View File

@@ -0,0 +1,54 @@
import Foundation
struct Site {
static let linkPreviewDesiredImageWidth = 1600
let elements: [SiteElement]
let metadata: Metadata
let inputFolder: URL
init?(folder: URL) throws {
self.inputFolder = folder
guard let metadata = try Metadata(in: folder) else {
return nil
}
guard !metadata.languages.isEmpty else {
throw GenerationError.invalidLanguageSpecification("No languages specified in site.json")
}
self.metadata = metadata
self.elements = try FileSystem.folders(in: folder)
.filter { !metadata.ignoredSubFolders.contains($0.lastPathComponent) }
.compactMap { sectionUrl in
return try Section(
folder: sectionUrl, path: sectionUrl.lastPathComponent)
}
print("Loaded site with \(elements.count) sections and \(metadata.languages.count) languages")
// Create example metadata
_ = try? Page.Metadata(in: folder)
_ = try? Section.Metadata(in: folder)
}
}
extension Site: LocalizedMetadataContainer {
}
extension Site: SiteElement {
var sortIndex: Int? { 0 }
var sortDate: Date? { nil }
var path: String { "" }
func cornerText(for language: String) -> String? { nil }
func backLinkText(for language: String) throws -> String? {
localized(for: language).backLinkText
}
}

View File

@@ -0,0 +1,199 @@
import Foundation
protocol SiteElement {
/**
The sort index for the element when manual sorting is specified for the parent.
- Note: Elements are sorted in ascending order.
*/
var sortIndex: Int? { get }
/**
The date used for sorting of the element, if automatic sorting is specified by the parent.
- Note: Elements are sorted by newest first.
*/
var sortDate: Date? { get }
/**
The path to the element's folder in the source hierarchy (without a leading slash).
*/
var path: String { get }
/**
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.
*/
var inputFolder: URL { get }
/**
The localized title of the element.
This title is used as large text in overview pages, or as the `<h1>` title on pages. If no separate link preview title is specified using a localized `linkPreview.title`, then this value is also used for link previews.
*/
func title(for language: String) -> String
/**
The optional text to display in a thumbnail corner.
- Note: This text is only displayed for large thumbnails.
*/
func cornerText(for language: String) -> String?
/**
The url to the element in the given language.
If the `externalUrl` property is not set for the page metadata in the given language, then the standard path is returned.
- If this value starts with a slash, it is considered an absolute url to the same domain
- If the value starts with `http://` or `https://` it is considered an external url
- Otherwise the value is treated as a path from the root of the site.
*/
func fullPageUrl(for language: String) -> String
/**
All elements contained within the element.
If the element is a section, then this property contains the pages within.
*/
var elements: [SiteElement] { get }
func backLinkText(for language: String) throws -> String?
}
extension SiteElement {
func fullPageUrl(for language: String) -> String {
localizedPath(for: language)
}
}
extension SiteElement {
/**
The id of the section to which this element contains.
This property is used to highlight the active section in the top bar.
The section id is the folder name of the top-level section
*/
var sectionId: String {
path.components(separatedBy: "/").first!
}
static var defaultThumbnailFileName: String { "thumbnail.jpg" }
static func thumbnailFileNameLocalized(for language: String) -> String {
defaultThumbnailFileName.insert("-\(language)", beforeLast: ".")
}
var containedFolder: String {
inputFolder.lastPathComponent
}
var containsElements: Bool {
!elements.isEmpty
}
var hasNestingElements: Bool {
elements.contains { $0.containsElements }
}
/**
Get the full path of the thumbnail image for the language (relative to the root folder).
*/
func thumbnailFilePath(for language: String) -> String {
let specificImageName = Self.thumbnailFileNameLocalized(for: language)
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
guard specificImageUrl.exists else {
return "\(path)/\(Self.defaultThumbnailFileName)"
}
return "\(path)/\(specificImageName)"
}
/**
Gets the thumbnail image for the element.
If a localized thumbnail exists, then this image name is returned.
*/
func thumbnailName(for language: String) -> String {
let specificImageName = "thumbnail-\(language).jpg"
let specificImageUrl = inputFolder.appendingPathComponent(specificImageName)
guard specificImageUrl.exists else {
return "\(inputFolder.lastPathComponent)/thumbnail.jpg"
}
return "\(inputFolder.lastPathComponent)/\(specificImageName)"
}
/**
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 {
guard !filePath.hasSuffix("/") && !filePath.hasSuffix("http") else {
return filePath
}
return "\(path)/\(filePath)"
}
func backLinkText(for language: String) throws -> String? { nil }
/**
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 {
path != "" ? "\(path)/\(language).html" : "\(language).html"
}
func relativePathToFileWithPath(_ filePath: String) -> String {
guard path != "" else {
return filePath
}
guard filePath.hasPrefix(path) else {
return filePath
}
return filePath.replacingOccurrences(of: path + "/", with: "")
}
private var additionalHeadContentUrl: URL {
inputFolder.appendingPathComponent("head.html")
}
var hasAdditionalHeadContent: Bool {
additionalHeadContentUrl.exists
}
func customHeadContent() throws -> String? {
let url = additionalHeadContentUrl
guard url.exists else {
return nil
}
return try wrap(.failedToOpenFile(url.path)) {
try String(contentsOf: url)
}
}
private var additionalFooterContentUrl: URL {
inputFolder.appendingPathComponent("footer.html")
}
var hasAdditionalFooterContent: Bool {
additionalFooterContentUrl.exists
}
func customFooterContent() throws -> String? {
let url = additionalFooterContentUrl
guard url.exists else {
return nil
}
return try wrap(.failedToOpenFile(url.path)) {
try String(contentsOf: url)
}
}
}
extension SiteElement {
func printContents() {
print(path)
elements.forEach { $0.printContents() }
}
}