Add navigation settings, fix page generation

This commit is contained in:
Christoph Hagen
2025-01-02 11:56:51 +01:00
parent 922ba4ebe7
commit 4d4275e072
43 changed files with 921 additions and 581 deletions

View File

@ -2,28 +2,34 @@ import Foundation
final class FeedPageGenerator {
let results: PageGenerationResults
let content: Content
init(content: Content) {
init(content: Content, results: PageGenerationResults) {
self.content = content
self.results = results
}
private func includeSwiper(in headers: inout Set<HeaderElement>) {
if let swiperCss = content.settings.posts.swiperCssFile {
headers.insert(.css(file: swiperCss, order: HeaderElement.swiperCssFileOrder))
results.require(file: swiperCss)
}
if let swiperJs = content.settings.posts.swiperJsFile {
headers.insert(.js(file: swiperJs, defer: true))
results.require(file: swiperJs)
}
}
func generatePage(language: ContentLanguage,
posts: [FeedEntryData],
title: String,
description: String,
description: String?,
showTitle: Bool,
pageNumber: Int,
totalPages: Int) -> String {
totalPages: Int,
languageButtonUrl: String) -> String {
var headers = content.defaultPageHeaders
var footer = ""
if posts.contains(where: { $0.images.count > 1 }) {
@ -32,12 +38,23 @@ final class FeedPageGenerator {
footer = swiperInitScript(posts: posts)
}
let page = GenericPage(
let iconUrl = content.settings.navigation.localized(in: language).rootUrl
let languageButton = NavigationBar.Link(
text: language.next.rawValue,
url: languageButtonUrl)
let pageHeader = PageHeader(
language: language,
title: title,
description: description,
iconUrl: iconUrl,
languageButton: languageButton,
links: content.navigationBar(in: language),
headers: headers,
icons: [])
let page = GenericPage(
header: pageHeader,
additionalFooter: footer) { content in
if showTitle {
content += "<h1>\(title)</h1>"
@ -48,7 +65,6 @@ final class FeedPageGenerator {
if totalPages > 1 {
content += PostFeedPageNavigation(currentPage: pageNumber, numberOfPages: totalPages, language: language).content
}
}
return page.content
}

View File

@ -1,12 +1,5 @@
import Foundation
struct LocalizedPageId: Hashable {
let language: ContentLanguage
let pageId: String
}
final class GenerationResults: ObservableObject {
/// The files that could not be accessed
@ -181,6 +174,10 @@ final class GenerationResults: ObservableObject {
func unsaved(_ path: String) {
update { self.unsavedOutputFiles.insert(path) }
}
func empty(_ page: LocalizedPageId) {
update {self.emptyPages.insert(page) }
}
}
private extension Dictionary where Value == Set<ItemId> {

View File

@ -0,0 +1,24 @@
struct BoxCommandProcessor: CommandProcessor {
let commandType: ShorthandMarkdownKey = .box
let results: PageGenerationResults
init(content: Content, results: PageGenerationResults) {
self.results = results
}
/**
Format: `![box](<title>;<body>)`
*/
func process(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count > 1 else {
results.invalid(command: .box, markdown)
return ""
}
let title = arguments[0]
let text = arguments.dropFirst().joined(separator: ";")
return ContentBox(title: title, text: text).content
}
}

View File

@ -3,7 +3,6 @@ struct IconCommandProcessor: CommandProcessor {
let commandType: ShorthandMarkdownKey = .icons
let results: PageGenerationResults
init(content: Content, results: PageGenerationResults) {

View File

@ -13,7 +13,7 @@ struct InlineLinkProcessor {
let language: ContentLanguage
func handleLink(html: String, markdown: Substring) -> String {
func process(html: String, markdown: Substring) -> String {
let url = markdown.between("(", and: ")")
if url.hasPrefix(pageLinkMarker) {
return handleInlinePageLink(url: url, html: html, markdown: markdown)
@ -56,7 +56,7 @@ struct InlineLinkProcessor {
return markdown.between("[", and: "]")
}
results.linked(to: tag)
let tagPath = content.absoluteUrlToTag(tag, language: language)
let tagPath = tag.absoluteUrl(in: language)
return html.replacingOccurrences(of: textToChange, with: tagPath)
}

View File

@ -0,0 +1,25 @@
import Splash
struct PageCodeProcessor {
private let codeHighlightFooter = "<script>hljs.highlightAll();</script>"
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
let results: PageGenerationResults
init(results: PageGenerationResults) {
self.results = results
}
func process(_ html: String, markdown: Substring) -> String {
guard markdown.starts(with: "```swift") else {
results.require(header: .codeHightlighting)
results.require(footer: codeHighlightFooter)
return html // Just use normal code highlighting
}
// Highlight swift code using Splash
let code = markdown.between("```swift", and: "```").trimmed
return "<pre><code>" + swift.highlight(code) + "</pre></code>"
}
}

View File

@ -0,0 +1,153 @@
import SwiftSoup
/**
Handles both inline HTML and the external HTML command
*/
struct PageHtmlProcessor: CommandProcessor {
let commandType: ShorthandMarkdownKey = .includedHtml
let results: PageGenerationResults
let content: Content
init(content: Content, results: PageGenerationResults) {
self.content = content
self.results = results
}
/**
Handle the HTML command
Format: `![html](<fileId>)`
*/
func process(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count == 1 else {
results.invalid(command: .includedHtml, markdown)
return ""
}
let fileId = arguments[0]
guard let file = content.file(fileId) else {
results.missing(file: fileId, source: "External HTML command")
return ""
}
let content = file.textContent()
checkResources(in: content)
return content
}
/**
Handle inline HTML
*/
func process(_ html: String, markdown: Substring) -> String {
checkResources(in: html)
return html
}
private func checkResources(in html: String) {
let document: Document
do {
document = try SwiftSoup.parse(html)
} catch {
results.warning("Failed to parse inline HTML: \(error)")
return
}
checkImages(in: document)
checkLinks(in: document)
checkSourceSets(in: document)
}
private func checkImages(in document: Document) {
let srcAttributes: [String]
do {
let imgElements = try document.select("img")
srcAttributes = try imgElements.array()
.compactMap { try $0.attr("src") }
.filter { !$0.trimmed.isEmpty }
} catch {
results.warning("Failed to check 'src' attributes of <img> elements in inline HTML: \(error)")
return
}
for src in srcAttributes {
results.warning("Found image in html: \(src)")
}
}
private func checkLinks(in document: Document) {
let hrefs: [String]
do {
let linkElements = try document.select("a")
hrefs = try linkElements.array()
.compactMap { try $0.attr("href").trimmed }
.filter { !$0.isEmpty }
} catch {
results.warning("Failed to check 'href' attributes of <a> elements in inline HTML: \(error)")
return
}
for url in hrefs {
if url.hasPrefix("http://") || url.hasPrefix("https://") {
results.externalLink(to: url)
} else {
results.warning("Relative link in HTML: \(url)")
}
}
}
private func checkSourceSets(in document: Document) {
let sources: [Element]
do {
sources = try document.select("source").array()
} catch {
results.warning("Failed to find <source> elements in inline HTML: \(error)")
return
}
}
private func checkSourceSetAttributes(sources: [Element]) {
let srcSets: [String]
do {
srcSets = try sources
.compactMap { try $0.attr("srcset") }
.filter { !$0.trimmed.isEmpty }
} catch {
results.warning("Failed to check 'srcset' attributes of <source> elements in inline HTML: \(error)")
return
}
for src in srcSets {
results.warning("Found source set in html: \(src)")
}
}
private func checkSourceAttributes(sources: [Element]) {
let srcAttributes: [String]
do {
srcAttributes = try sources
.compactMap { try $0.attr("src") }
.filter { !$0.trimmed.isEmpty }
} catch {
results.warning("Failed to check 'src' attributes of <source> elements in inline HTML: \(error)")
return
}
for src in srcAttributes {
guard content.isValidIdForFile(src) else {
results.warning("Found source in html: \(src)")
continue
}
guard let file = content.file(src) else {
results.warning("Found source in html: \(src)")
continue
}
#warning("Either find files by their full path, or replace file id with full path")
results.require(file: file)
}
}
}

View File

@ -1,29 +1,33 @@
import Foundation
import Ink
import Splash
import SwiftSoup
final class PageContentParser {
private static let codeHighlightFooter = "<script>hljs.highlightAll();</script>"
private let swift = SyntaxHighlighter(format: HTMLOutputFormat())
private let language: ContentLanguage
private let content: Content
private let results: PageGenerationResults
// MARK: Command handlers
private let buttonHandler: ButtonCommandProcessor
private let labelHandler: LabelsCommandProcessor
private let audioPlayer: AudioPlayerCommandProcessor
private let icons: IconCommandProcessor
private let box: BoxCommandProcessor
private let html: PageHtmlProcessor
// MARK: Other handlers
private let inlineLink: InlineLinkProcessor
private let icons: IconCommandProcessor
private let code: PageCodeProcessor
var largeImageWidth: Int {
content.settings.pages.largeImageWidth
@ -40,127 +44,25 @@ final class PageContentParser {
self.buttonHandler = .init(content: content, results: results)
self.labelHandler = .init(content: content, results: results)
self.audioPlayer = .init(content: content, results: results)
self.inlineLink = .init(content: content, results: results, language: language)
self.icons = .init(content: content, results: results)
self.box = .init(content: content, results: results)
self.html = .init(content: content, results: results)
self.inlineLink = .init(content: content, results: results, language: language)
self.code = .init(results: results)
}
func generatePage(from content: String) -> String {
let parser = MarkdownParser(modifiers: [
Modifier(target: .images, closure: processMarkdownImage),
Modifier(target: .codeBlocks, closure: handleCode),
Modifier(target: .links, closure: inlineLink.handleLink),
Modifier(target: .html, closure: handleHTML),
Modifier(target: .codeBlocks, closure: code.process),
Modifier(target: .links, closure: inlineLink.process),
Modifier(target: .html, closure: html.process),
Modifier(target: .headings, closure: handleHeadlines)
])
return parser.html(from: content)
}
private func handleCode(html: String, markdown: Substring) -> String {
guard markdown.starts(with: "```swift") else {
results.require(header: .codeHightlighting)
results.require(footer: PageContentParser.codeHighlightFooter)
return html // Just use normal code highlighting
}
// Highlight swift code using Splash
let code = markdown.between("```swift", and: "```").trimmed
return "<pre><code>" + swift.highlight(code) + "</pre></code>"
}
private func handleHTML(html: String, _: Substring) -> String {
findResourcesInHtml(html: html)
return html
}
private func findResourcesInHtml(html: String) {
findImages(in: html)
findLinks(in: html)
findSourceSets(in: html)
}
private func findImages(in markdown: String) {
do {
// Parse the HTML string
let document = try SwiftSoup.parse(markdown)
// Select all 'img' elements
let imgElements = try document.select("img")
// Extract the 'src' attributes from each 'img' element
let srcAttributes = try imgElements.array()
.compactMap { try $0.attr("src") }
.filter { !$0.trimmed.isEmpty }
for src in srcAttributes {
results.warning("Found image in html: \(src)")
}
} catch {
print("Error parsing HTML: \(error)")
}
}
private func findLinks(in markdown: String) {
do {
// Parse the HTML string
let document = try SwiftSoup.parse(markdown)
// Select all 'img' elements
let linkElements = try document.select("a")
// Extract the 'src' attributes from each 'img' element
let srcAttributes = try linkElements.array()
.compactMap { try $0.attr("href").trimmed }
.filter { !$0.isEmpty }
for url in srcAttributes {
if url.hasPrefix("http://") || url.hasPrefix("https://") {
results.externalLink(to: url)
} else {
results.warning("Relative link in HTML: \(url)")
}
}
} catch {
print("Error parsing HTML: \(error)")
}
}
private func findSourceSets(in markdown: String) {
do {
// Parse the HTML string
let document = try SwiftSoup.parse(markdown)
// Select all 'img' elements
let linkElements = try document.select("source")
// Extract the 'src' attributes from each 'img' element
let srcsetAttributes = try linkElements.array()
.compactMap { try $0.attr("srcset") }
.filter { !$0.trimmed.isEmpty }
for src in srcsetAttributes {
results.warning("Found source set in html: \(src)")
}
let srcAttributes = try linkElements.array()
.compactMap { try $0.attr("src") }
.filter { !$0.trimmed.isEmpty }
for src in srcAttributes {
guard content.isValidIdForFile(src) else {
results.warning("Found source in html: \(src)")
continue
}
guard let file = content.file(src) else {
results.warning("Found source in html: \(src)")
continue
}
#warning("Either find files by their full path, or replace file id with full path")
results.require(file: file)
}
} catch {
print("Error parsing HTML: \(error)")
}
}
/**
Modify headlines by extracting an id from the headline and adding it into the html element
@ -218,9 +120,9 @@ final class PageContentParser {
case .pageLink:
return handlePageLink(arguments, markdown: markdown)
case .includedHtml:
return handleExternalHtml(arguments, markdown: markdown)
return self.html.process(arguments, markdown: markdown)
case .box:
return handleSimpleBox(arguments, markdown: markdown)
return box.process(arguments, markdown: markdown)
case .model:
return handleModel(arguments, markdown: markdown)
case .svg:
@ -343,37 +245,6 @@ final class PageContentParser {
return option
}
/**
Format: `![html](<fileId>)`
*/
private func handleExternalHtml(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count == 1 else {
results.invalid(command: .includedHtml, markdown)
return ""
}
let fileId = arguments[0]
guard let file = content.file(fileId) else {
results.missing(file: fileId, source: "External HTML command")
return ""
}
let content = file.textContent()
findResourcesInHtml(html: content)
return content
}
/**
Format: `![box](<title>;<body>)`
*/
private func handleSimpleBox(_ arguments: [String], markdown: Substring) -> String {
guard arguments.count > 1 else {
results.invalid(command: .box, markdown)
return ""
}
let title = arguments[0]
let text = arguments.dropFirst().joined(separator: ";")
return ContentBox(title: title, text: text).content
}
/**
Format: `![page](<pageId>)`
*/

View File

@ -73,6 +73,8 @@ final class PageGenerationResults: ObservableObject {
/// The files that could not be saved to the output folder
private(set) var unsavedOutputFiles: [String: Set<ItemType>] = [:]
private(set) var pageIsEmpty: Bool
init(itemId: ItemId, delegate: GenerationResults) {
self.itemId = itemId
self.delegate = delegate
@ -94,33 +96,7 @@ final class PageGenerationResults: ObservableObject {
invalidCommands = []
warnings = []
unsavedOutputFiles = [:]
}
private init(other: PageGenerationResults) {
self.itemId = other.itemId
self.delegate = other.delegate
inaccessibleFiles = other.inaccessibleFiles
unparsableFiles = other.unparsableFiles
missingFiles = other.missingFiles
missingLinkedFiles = other.missingLinkedFiles
missingLinkedTags = other.missingLinkedTags
missingLinkedPages = other.missingLinkedPages
requiredHeaders = other.requiredHeaders
requiredFooters = other.requiredFooters
requiredIcons = other.requiredIcons
linkedPages = other.linkedPages
linkedTags = other.linkedTags
externalLinks = other.externalLinks
usedFiles = other.usedFiles
requiredFiles = other.requiredFiles
imagesToGenerate = other.imagesToGenerate
invalidCommands = other.invalidCommands
warnings = other.warnings
unsavedOutputFiles = other.unsavedOutputFiles
}
func copy() -> PageGenerationResults {
.init(other: self)
pageIsEmpty = false
}
// MARK: Adding entries
@ -231,5 +207,11 @@ final class PageGenerationResults: ObservableObject {
unsavedOutputFiles[path, default: []].insert(source)
delegate.unsaved(path)
}
func markPageAsEmpty() {
guard case .page(let page) = itemId.itemType else { return }
pageIsEmpty = true
delegate.empty(.init(language: itemId.language, pageId: page.id))
}
}

View File

@ -19,13 +19,32 @@ final class PageGenerator {
return result
}
private func makeEmptyPageContent(in language: ContentLanguage) -> String {
switch language {
case .english:
return ContentBox(
title: "Content not available",
text: "This page is not available yet. Try the German version or check back later.")
.content
case .german:
return ContentBox(
title: "Inhalt nicht verfügbar",
text: "Diese Seite ist noch nicht verfügbar. Versuche die englische Version oder komm später hierher zurück.")
.content
}
}
func generate(page: Page, language: ContentLanguage, results: PageGenerationResults) -> String? {
let contentGenerator = PageContentParser(
content: content,
language: language, results: results)
guard let rawPageContent = content.storage.pageContent(for: page.id, language: language) else {
return nil
let rawPageContent: String
if let existing = content.storage.pageContent(for: page.id, language: language) {
rawPageContent = existing
} else {
rawPageContent = makeEmptyPageContent(in: language)
results.markPageAsEmpty()
}
let pageContent = contentGenerator.generatePage(from: rawPageContent)
@ -34,27 +53,41 @@ final class PageGenerator {
let tags: [FeedEntryData.Tag] = page.tags.map { tag in
.init(name: tag.localized(in: language).name,
url: content.absoluteUrlToTag(tag, language: language))
url: tag.absoluteUrl(in: language))
}
let headers = makeHeaders(requiredItems: results.requiredHeaders)
results.require(files: headers.compactMap { $0.file })
let fullPage = ContentPage(
language: language,
dateString: page.dateText(in: language),
title: localized.title,
showTitle: !localized.hideTitle,
tags: tags,
linkTitle: localized.linkPreviewTitle ?? localized.title,
description: localized.linkPreviewDescription ?? "",
navigationBarLinks: content.navigationBar(in: language),
pageContent: pageContent,
headers: headers,
footers: results.requiredFooters.sorted(),
icons: results.requiredIcons)
.content
let iconUrl = content.settings.navigation.localized(in: language).rootUrl
let languageUrl = page.absoluteUrl(in: language.next)
let languageButton = NavigationBar.Link(
text: language.next.rawValue,
url: languageUrl)
return fullPage
let pageHeader = PageHeader(
language: language,
title: localized.linkPreviewTitle ?? localized.title,
description: localized.linkPreviewDescription,
iconUrl: iconUrl,
languageButton: languageButton,
links: content.navigationBar(in: language),
headers: headers,
icons: results.requiredIcons)
let fullPage = GenericPage(
header: pageHeader,
additionalFooter: results.requiredFooters.sorted().joined()) { content in
content += "<article>"
if !localized.hideTitle {
content += "<h3>\(page.dateText(in: language))</h3>"
content += "<h1>\(localized.title)</h1>"
content += TagList(tags: tags).content
}
content += pageContent
content += "</article>"
}
return fullPage.content
}
}

View File

@ -0,0 +1,25 @@
struct FeedGeneratorSource: PostListPageGeneratorSource {
let language: ContentLanguage
let content: Content
let results: PageGenerationResults
var showTitle: Bool {
false
}
var pageTitle: String {
content.settings.localized(in: language).title
}
var pageDescription: String {
content.settings.localized(in: language).description
}
func pageUrlPrefix(for language: ContentLanguage) -> String {
content.settings.localized(in: language).feedUrlPrefix
}
}

View File

@ -2,42 +2,26 @@ import Foundation
final class PostListPageGenerator {
private let language: ContentLanguage
let source: PostListPageGeneratorSource
private let content: Content
init(source: PostListPageGeneratorSource) {
self.source = source
}
private let results: PageGenerationResults
private let showTitle: Bool
private let pageTitle: String
private let pageDescription: String
/// The url of the page, excluding the extension
private let pageUrlPrefix: String
init(language: ContentLanguage,
content: Content,
results: PageGenerationResults,
showTitle: Bool, pageTitle: String,
pageDescription: String,
pageUrlPrefix: String) {
self.language = language
self.content = content
self.results = results
self.showTitle = showTitle
self.pageTitle = pageTitle
self.pageDescription = pageDescription
self.pageUrlPrefix = pageUrlPrefix
private var language: ContentLanguage {
source.language
}
private var mainContentMaximumWidth: Int {
content.settings.posts.contentWidth
source.content.settings.posts.contentWidth
}
private var postsPerPage: Int {
content.settings.posts.postsPerPage
source.content.settings.posts.postsPerPage
}
private func pageUrl(in language: ContentLanguage, pageNumber: Int) -> String {
"\(source.pageUrlPrefix(for: language))/\(pageNumber).html"
}
func createPages(for posts: [Post]) {
@ -67,7 +51,7 @@ final class PostListPageGenerator {
let tags: [FeedEntryData.Tag] = post.tags.filter { $0.isVisible }.map { tag in
.init(name: tag.localized(in: language).name,
url: content.absoluteUrlToTag(tag, language: language))
url: tag.absoluteUrl(in: language))
}
let images = localized.images.map(createFeedImage)
@ -82,25 +66,28 @@ final class PostListPageGenerator {
images: images)
}
let feedPageGenerator = FeedPageGenerator(content: content)
let feedPageGenerator = FeedPageGenerator(content: source.content, results: source.results)
let languageButtonUrl = pageUrl(in: language.next, pageNumber: pageIndex)
let fileContent = feedPageGenerator.generatePage(
language: language,
posts: posts,
title: pageTitle,
description: pageDescription,
showTitle: showTitle,
title: source.pageTitle,
description: source.pageDescription,
showTitle: source.showTitle,
pageNumber: pageIndex,
totalPages: pageCount)
let filePath = "\(pageUrlPrefix)/\(pageIndex).html"
totalPages: pageCount,
languageButtonUrl: languageButtonUrl)
let filePath = pageUrl(in: language, pageNumber: pageIndex)
guard save(fileContent, to: filePath) else {
results.unsavedOutput(filePath, source: .feed)
source.results.unsavedOutput(filePath, source: .feed)
return
}
}
private func createFeedImage(for image: FileResource) -> FeedEntryData.Image {
results.requireImageSet(for: image, size: mainContentMaximumWidth)
source.results.requireImageSet(for: image, size: mainContentMaximumWidth)
return .init(
rawImagePath: image.absoluteUrl,
width: mainContentMaximumWidth,
@ -109,6 +96,6 @@ final class PostListPageGenerator {
}
private func save(_ content: String, to relativePath: String) -> Bool {
self.content.storage.write(content, to: relativePath)
source.content.storage.write(content, to: relativePath)
}
}

View File

@ -0,0 +1,17 @@
protocol PostListPageGeneratorSource {
var language: ContentLanguage { get }
var content: Content { get }
var results: PageGenerationResults { get }
var showTitle: Bool { get }
var pageTitle: String { get }
var pageDescription: String { get }
func pageUrlPrefix(for language: ContentLanguage) -> String
}

View File

@ -0,0 +1,27 @@
struct TagPageGeneratorSource: PostListPageGeneratorSource {
let language: ContentLanguage
let content: Content
let results: PageGenerationResults
let tag: Tag
var showTitle: Bool {
true
}
var pageTitle: String {
tag.localized(in: language).name
}
var pageDescription: String {
tag.localized(in: language).description ?? ""
}
func pageUrlPrefix(for language: ContentLanguage) -> String {
tag.absoluteUrl(in: language)
}
}

View File

@ -37,7 +37,7 @@ enum ShorthandMarkdownKey: String {
/// Format: `![tag](<tagId>)`
case tagLink = "tag"
/// Additional HTML code include verbatim into the page.
/// Additional HTML code included verbatim into the page.
/// Format: `![html](<fileId>)`
case includedHtml = "html"