Improve asset handling

This commit is contained in:
Christoph Hagen 2024-12-16 15:36:58 +01:00
parent 31d1ecb8bd
commit b22b76fd32
21 changed files with 264 additions and 85 deletions

@ -127,7 +127,7 @@
E29D317D2D086AB00051B7F4 /* Int+Random.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D317C2D086AAE0051B7F4 /* Int+Random.swift */; };
E29D317F2D086F4C0051B7F4 /* StatisticsIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D317E2D086F490051B7F4 /* StatisticsIcons.swift */; };
E29D31832D0A43DB0051B7F4 /* RelatedPageLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31822D0A43D60051B7F4 /* RelatedPageLink.swift */; };
E29D31852D0AE8EE0051B7F4 /* RequiredHeaders.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31842D0AE8EE0051B7F4 /* RequiredHeaders.swift */; };
E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */; };
E29D31892D0AED1F0051B7F4 /* ModelViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D31882D0AED1B0051B7F4 /* ModelViewer.swift */; };
E29D318B2D0B07EE0051B7F4 /* ContentBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D318A2D0B07E60051B7F4 /* ContentBox.swift */; };
E29D318E2D0B2E680051B7F4 /* PageSettingsContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E29D318D2D0B2E640051B7F4 /* PageSettingsContentView.swift */; };
@ -311,7 +311,7 @@
E29D317C2D086AAE0051B7F4 /* Int+Random.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Int+Random.swift"; sourceTree = "<group>"; };
E29D317E2D086F490051B7F4 /* StatisticsIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsIcons.swift; sourceTree = "<group>"; };
E29D31822D0A43D60051B7F4 /* RelatedPageLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedPageLink.swift; sourceTree = "<group>"; };
E29D31842D0AE8EE0051B7F4 /* RequiredHeaders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequiredHeaders.swift; sourceTree = "<group>"; };
E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnownHeaderElement.swift; sourceTree = "<group>"; };
E29D31882D0AED1B0051B7F4 /* ModelViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelViewer.swift; sourceTree = "<group>"; };
E29D318A2D0B07E60051B7F4 /* ContentBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentBox.swift; sourceTree = "<group>"; };
E29D318D2D0B2E640051B7F4 /* PageSettingsContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageSettingsContentView.swift; sourceTree = "<group>"; };
@ -452,7 +452,7 @@
E22990232D0EDBD0009F8D77 /* HeaderElement.swift */,
E29D31B62D0DAC030051B7F4 /* Page Content */,
E29D318F2D0B34870051B7F4 /* PageContentAnomaly.swift */,
E29D31842D0AE8EE0051B7F4 /* RequiredHeaders.swift */,
E29D31842D0AE8EE0051B7F4 /* KnownHeaderElement.swift */,
E25DA5222CFF6C2600AEF16D /* ImageGenerator.swift */,
E218502E2CFAF6990090B18B /* LocalizedWebsiteGenerator.swift */,
E25DA5792D01C63E00AEF16D /* PageContentGenerator.swift */,
@ -921,7 +921,7 @@
E242520A2C52C9260029FF16 /* ContentLanguage.swift in Sources */,
E2B85F452C429ED60047CD0C /* ImageGallery.swift in Sources */,
E2B85F3B2C428F0E0047CD0C /* Post.swift in Sources */,
E29D31852D0AE8EE0051B7F4 /* RequiredHeaders.swift in Sources */,
E29D31852D0AE8EE0051B7F4 /* KnownHeaderElement.swift in Sources */,
E25DA51B2CFF08BB00AEF16D /* PostFeedPageNavigation.swift in Sources */,
E29D317F2D086F4C0051B7F4 /* StatisticsIcons.swift in Sources */,
E2A21C082CB17B870060935B /* TagView.swift in Sources */,

@ -8,27 +8,12 @@ final class FeedPageGenerator {
self.content = content
}
var swiperIncludes: [HeaderElement] {
var result = [HeaderElement]()
private func includeSwiper(in headers: inout Set<HeaderElement>) {
if let swiperCss = content.settings.posts.swiperCssFile {
result.append(.css(swiperCss))
} else {
#warning("Add warning message")
headers.insert(.css(file: swiperCss, order: HeaderElement.swiperCssFileOrder))
}
if let swiperJs = content.settings.posts.swiperJsFile {
result.append(.js(file: swiperJs, defer: true))
} else {
#warning("Add warning message")
}
return result
}
var defaultHeaders: [HeaderElement] {
if let header = content.settings.posts.defaultCssFile {
return [.css(header)]
} else {
#warning("Add warning message")
return []
headers.insert(.js(file: swiperJs, defer: true))
}
}
@ -39,10 +24,11 @@ final class FeedPageGenerator {
showTitle: Bool,
pageNumber: Int,
totalPages: Int) -> String {
var headers = defaultHeaders
var headers = content.defaultPageHeaders
var footer = ""
if posts.contains(where: { $0.images.count > 1 }) {
headers += swiperIncludes
// Sort swiper style sheet before default style sheet
includeSwiper(in: &headers)
footer = swiperInitScript(posts: posts)
}
@ -68,15 +54,14 @@ final class FeedPageGenerator {
}
func swiperInitScript(posts: [FeedEntryData]) -> String {
var result = "<script>"
var result = "<script> window.onload = () => { "
for post in posts {
guard post.images.count > 1 else {
continue
}
result += ImageGallery.swiperInit(id: post.entryId)
}
result += "</script>"
result += "}; </script>"
return result
}
}

@ -1,33 +1,135 @@
#warning("Add remaining header elements")
// <meta name="msapplication-TileColor" content="#da532c">
// <meta name="theme-color" content="#ffffff">
// <meta name="msapplication-config" content="/assets/icons/browserconfig.xml?v=1">
// TODO: Move to settings?
extension HeaderElement {
static let swiperCssFileOrder = 41
static let defaultCssFileOrder = 42
static let audioPlayerCssOrder = 43
}
enum HeaderElement {
case css(FileResource)
/// Order: 10-19
case icon(file: FileResource, size: Int, rel: String)
/// Order: From 40-99, lower numbers appear first
case css(file: FileResource, order: Int)
/// Order: 20-29
case js(file: FileResource, defer: Bool)
/// Order: 30-39
case jsModule(FileResource)
case author(String)
case title(String)
case description(String)
case charset
case viewport
case robots
var order: Int {
switch self {
case .charset:
return 1
case .robots:
return 2
case .viewport:
return 3
case .icon:
return 10
case .css(_, let order):
return order
case .js:
return 20
case .jsModule:
return 30
case .author:
return 100
case .title:
return 101
case .description:
return 102
}
}
}
extension HeaderElement: Hashable {
}
extension HeaderElement: Comparable {
static func < (lhs: HeaderElement, rhs: HeaderElement) -> Bool {
lhs.order < rhs.order
}
}
extension HeaderElement {
var content: String {
switch self {
case .css(let file):
case .icon(let file, let size, let rel):
return "<link rel='\(rel)' sizes='\(size)x\(size)' href='\(file.assetUrl)'>"
case .css(let file, _):
return "<link rel='stylesheet' href='\(file.assetUrl)' />"
case .js(let file, let deferred):
let deferText = deferred ? " defer" : ""
return "<script src='\(file.assetUrl)'\(deferText)></script>"
case .jsModule(let file):
return "<script type='module' src='\(file.assetUrl)'></script>"
case .author(let author):
return "<meta name='author' content='\(author)'>"
case .title(let title):
return "<title>\(title)</title>"
case .description(let description):
return "<meta name='description' content='\(description)'>"
return "<meta name='description' content=\"\(description)\">"
case .charset:
return "<meta charset='utf-8' />"
case .viewport:
return "<meta name='viewport' content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1' />"
case .robots:
return "<meta name='robots' content='noindex'>"
}
}
}
extension HeaderElement: CustomStringConvertible {
var description: String {
switch self {
case .icon(let file, _, _):
return file.description
case .css(let file, _):
return file.description
case .js(let file, let deferred):
return file.description + (deferred ? " (deferred)" : "")
case .jsModule(let file):
return file.description
case .author:
return "author"
case .title:
return "title"
case .description:
return "description"
case .charset:
return "charset"
case .viewport:
return "viewport"
case .robots:
return "robots"
}
}
}

@ -1,5 +1,5 @@
enum HeaderFile: Int {
enum KnownHeaderElement: Int {
case codeHightlighting = 4
@ -23,7 +23,7 @@ enum HeaderFile: Int {
}
case .audioPlayerCss:
if let file = content.settings.pages.audioPlayerCssFile {
return .css(file)
return .css(file: file, order: HeaderElement.audioPlayerCssOrder)
}
case .audioPlayerJs:
if let file = content.settings.pages.audioPlayerJsFile {
@ -34,13 +34,27 @@ enum HeaderFile: Int {
}
}
extension HeaderFile: Comparable {
extension KnownHeaderElement: Comparable {
static func < (lhs: HeaderFile, rhs: HeaderFile) -> Bool {
static func < (lhs: KnownHeaderElement, rhs: KnownHeaderElement) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
typealias RequiredHeaders = Set<HeaderFile>
extension KnownHeaderElement: CustomStringConvertible {
var description: String {
switch self {
case .codeHightlighting:
return "code-highlighting"
case .modelViewer:
return "model-viewer"
case .audioPlayerCss:
return "audio-player-css"
case .audioPlayerJs:
return "audio-player-js"
}
}
}

@ -2,10 +2,23 @@ import Foundation
final class LocalizedWebsiteGenerator {
private let content: Content
let language: ContentLanguage
private let imageGenerator: ImageGenerator
private let localizedPostSettings: LocalizedPostSettings
init(content: Content, language: ContentLanguage) {
self.language = language
self.content = content
self.localizedPostSettings = content.settings.localized(in: language)
self.imageGenerator = ImageGenerator(
storage: content.storage,
relativeImageOutputPath: content.settings.paths.imagesOutputFolderPath)
}
private var outputDirectory: URL {
content.settings.outputDirectory
}
@ -18,19 +31,6 @@ final class LocalizedWebsiteGenerator {
CGFloat(content.settings.posts.contentWidth)
}
private let content: Content
private let imageGenerator: ImageGenerator
init(content: Content, language: ContentLanguage) {
self.language = language
self.content = content
self.localizedPostSettings = content.settings.localized(in: language)
self.imageGenerator = ImageGenerator(
storage: content.storage,
relativeImageOutputPath: content.settings.paths.imagesOutputFolderPath)
}
func generateWebsite(callback: (String) -> Void) -> Bool {
guard imageGenerator.prepareForGeneration() else {
return false

@ -45,7 +45,7 @@ final class PageGenerationResults: ObservableObject {
var invalidCommands: [(command: ShorthandMarkdownKey?, markdown: String)] = []
@Published
var requiredHeaders: RequiredHeaders = []
var requiredHeaders: Set<KnownHeaderElement> = []
@Published
var requiredFooters: Set<String> = []

@ -9,14 +9,15 @@ final class PageGenerator {
self.imageGenerator = imageGenerator
}
func makeHeaders(requiredItems: [HeaderFile]) -> [HeaderElement] {
var result = [HeaderElement]()
private func makeHeaders(requiredItems: Set<KnownHeaderElement>) -> Set<HeaderElement> {
var result = content.defaultPageHeaders
for item in requiredItems {
guard let header = item.header(content: content) else {
print("Missing header \(item)")
#warning("Add warning on missing file assignment")
continue
}
result.append(header)
result.insert(header)
}
return result
}
@ -39,7 +40,8 @@ final class PageGenerator {
url: content.absoluteUrlToTag(tag, language: language))
}
let headers = makeHeaders(requiredItems: contentGenerator.results.requiredHeaders.sorted())
let headers = makeHeaders(requiredItems: contentGenerator.results.requiredHeaders)
print("Headers for page: \(headers)")
let fullPage = ContentPage(
language: language,

@ -16,6 +16,7 @@ import SFSafeSymbols
#warning("Generate pages for posts")
#warning("Clean up mock content")
#warning("Show posts linking to a page")
#warning("Add author to settings and page headers")
@main
struct MainView: App {

@ -1,5 +1,54 @@
import Foundation
extension Content {
func generatePage(_ page: Page) -> Bool {
guard startGenerating() else { return false }
defer { endGenerating() }
for language in ContentLanguage.allCases {
guard generateInternal(page, in: language) else {
return false
}
}
return true
}
func generatePage(_ page: Page, in language: ContentLanguage) -> Bool {
guard startGenerating() else { return false }
defer { endGenerating() }
return generateInternal(page, in: language)
}
private func startGenerating() -> Bool {
guard !isGeneratingWebsite else {
return false
}
// TODO: Fix bug where multiple generating operations can be started
// due to dispatch of locking property on main queue
DispatchQueue.main.async {
self.isGeneratingWebsite = true
}
return true
}
private func endGenerating() {
DispatchQueue.main.async {
self.isGeneratingWebsite = false
}
}
private func generateInternal(_ page: Page, in language: ContentLanguage) -> Bool {
let generator = LocalizedWebsiteGenerator(
content: self,
language: language)
if !generator.generate(page: page) {
print("Generation failed")
return false
}
return true
}
private func makeCleanAbsolutePath(_ path: String) -> String {
("/" + path).replacingOccurrences(of: "//", with: "/")
}
@ -36,10 +85,20 @@ extension Content {
tags.first { $0.id == tagId }
}
// MARK: Generation input
func navigationBar(in language: ContentLanguage) -> [NavigationBar.Link] {
settings.navigationItems.map {
.init(text: $0.title(in: language),
url: $0.absoluteUrl(in: language))
}
}
var defaultPageHeaders: Set<HeaderElement> {
var result: Set<HeaderElement> = [.charset, .viewport]
if let defaultCss = settings.posts.defaultCssFile {
result.insert(.css(file: defaultCss, order: HeaderElement.defaultCssFileOrder))
}
return result
}
}

@ -25,6 +25,9 @@ final class Content: ObservableObject {
@Published
var results: [ItemId : PageGenerationResults] = [:]
@Published
var isGeneratingWebsite = false
@AppStorage("contentPath")
private var storedContentPath: String = ""

@ -137,3 +137,10 @@ final class FileResource: Item {
extension FileResource: LocalizedItem {
}
extension FileResource: CustomStringConvertible {
var description: String {
id
}
}

@ -44,7 +44,7 @@ final class PageSettings: ObservableObject {
defaultCssFile: defaultCssFile?.id,
codeHighlightingJsFile: codeHighlightingJsFile?.id,
audioPlayerJsFile: audioPlayerJsFile?.id,
audioPlayerCssFile: audioPlayerJsFile?.id,
audioPlayerCssFile: audioPlayerCssFile?.id,
modelViewerJsFile: modelViewerJsFile?.id)
}
}

@ -18,11 +18,11 @@ struct AudioPlayerScript: HtmlProducer {
}
func populate(_ result: inout String) {
result += "<script>\n"
result += "<script>window.onload = () => { "
result += "Amplitude.init({ songs: "
let songData = try! JSONEncoder().encode(items)
result += String(data: songData, encoding: .utf8)!
result += "});"
result += "}); }; " // Close Amplitude.init and window.onload
result += "function playEntry(index) { Amplitude.playSongAtIndex(index) };"
result += animatePlaylist
result += "</script>"

@ -24,7 +24,7 @@ struct ContentPage: HtmlProducer {
private let icons: Set<PageIcon>
init(language: ContentLanguage, dateString: String, title: String, tags: [FeedEntryData.Tag], linkTitle: String, description: String, navigationBarLinks: [NavigationBar.Link], pageContent: String, headers: [HeaderElement], footers: [String], icons: Set<PageIcon>) {
init(language: ContentLanguage, dateString: String, title: String, tags: [FeedEntryData.Tag], linkTitle: String, description: String, navigationBarLinks: [NavigationBar.Link], pageContent: String, headers: Set<HeaderElement>, footers: [String], icons: Set<PageIcon>) {
self.language = language
self.dateString = dateString
self.title = title
@ -33,7 +33,7 @@ struct ContentPage: HtmlProducer {
self.description = description
self.navigationBarLinks = navigationBarLinks
self.pageContent = pageContent
self.headers = headers
self.headers = headers.union([.title(title), .description(description)]).sorted()
self.footers = footers.joined()
self.icons = icons
}
@ -41,7 +41,7 @@ struct ContentPage: HtmlProducer {
func populate(_ result: inout String) {
// TODO: Add headers and footers from page content
result += "<!DOCTYPE html><html lang=\"\(language.rawValue)\">"
result += PageHead(items: [.charset, .viewport] + headers).content
result += PageHead(items: headers).content
result += "<body>"
result += NavigationBar(links: navigationBarLinks).content

@ -16,19 +16,21 @@ struct GenericPage {
let insertedContent: (inout String) -> Void
init(language: ContentLanguage, title: String, description: String, links: [NavigationBar.Link], headers: [HeaderElement], additionalFooter: String, insertedContent: @escaping (inout String) -> Void) {
init(language: ContentLanguage, title: String, description: String, links: [NavigationBar.Link], headers: Set<HeaderElement>, additionalFooter: String, insertedContent: @escaping (inout String) -> Void) {
self.language = language
self.title = title
self.description = description
self.links = links
self.headers = headers
self.headers = headers.union([.title(title), .description(description)]).sorted()
self.additionalFooter = additionalFooter
self.insertedContent = insertedContent
}
var content: String {
#warning("Consolidate this code with ContentPage")
var result = ""
result += "<!DOCTYPE html><html lang=\"\(language.rawValue)\">"
result += PageHead(items: [.charset, .viewport] + headers).content
result += PageHead(items: headers).content
result += "<body>"
result += NavigationBar(links: links).content
result += "<div class=\"content\"><div style=\"height: 70px;\"></div>"

@ -9,6 +9,15 @@ struct FilePropertyView: View {
@Binding
var selectedFile: FileResource?
let allowedType: FileFilterType?
init(title: LocalizedStringKey, footer: LocalizedStringKey, selectedFile: Binding<FileResource?>, allowedType: FileFilterType? = nil) {
self.title = title
self.footer = footer
self._selectedFile = selectedFile
self.allowedType = allowedType
}
@State
private var showFileSelectionSheet = false
@ -23,7 +32,7 @@ struct FilePropertyView: View {
}
}
.sheet(isPresented: $showFileSelectionSheet) {
FileSelectionView(selectedFile: $selectedFile)
FileSelectionView(selectedFile: $selectedFile, allowedType: allowedType)
}
}
}

@ -12,9 +12,6 @@ struct PageDetailView: View {
@ObservedObject
private var page: Page
@State
private var isGeneratingWebsite = false
@State
private var didGenerateWebsite: Bool?
@ -28,11 +25,11 @@ struct PageDetailView: View {
DetailTitle(
title: "Page",
text: "A page contains longer content")
HStack {
HStack(alignment: .firstTextBaseline) {
Button(action: generate) {
Text("Generate")
}
.disabled(isGeneratingWebsite)
.disabled(content.isGeneratingWebsite)
if let didGenerateWebsite {
if didGenerateWebsite {
Image(systemSymbol: .checkmarkCircleFill)
@ -93,20 +90,9 @@ struct PageDetailView: View {
print("Missing output folder")
return
}
isGeneratingWebsite = true
DispatchQueue.global(qos: .userInitiated).async {
var success = true
for language in ContentLanguage.allCases {
let generator = LocalizedWebsiteGenerator(
content: content,
language: language)
if !generator.generate(page: page) {
print("Generation failed")
success = false
}
}
let success = content.generatePage(page)
DispatchQueue.main.async {
isGeneratingWebsite = false
didGenerateWebsite = success
}
}

@ -42,6 +42,7 @@ struct NavigationBarSettingsView: View {
Text("Select the tags to show in the navigation bar. The number should be even.")
.foregroundStyle(.secondary)
}
.padding()
}
.sheet(isPresented: $showItemPicker) {
ItemSelectionView(

@ -33,28 +33,34 @@ struct PageSettingsDetailView: View {
FilePropertyView(
title: "Default CSS File",
footer: "The CSS file containing the styling of all pages",
selectedFile: $content.settings.pages.defaultCssFile)
selectedFile: $content.settings.pages.defaultCssFile,
allowedType: .text)
FilePropertyView(
title: "Code Highlighting File",
footer: "The JavaScript file to provide syntax highlighting of code blocks",
selectedFile: $content.settings.pages.codeHighlightingJsFile)
selectedFile: $content.settings.pages.codeHighlightingJsFile,
allowedType: .text)
FilePropertyView(
title: "Audio Player CSS File",
footer: "The CSS file to provide the style for the audio player",
selectedFile: $content.settings.pages.audioPlayerCssFile)
selectedFile: $content.settings.pages.audioPlayerCssFile,
allowedType: .text)
FilePropertyView(
title: "Audio Player JavaScript File",
footer: "The CSS file to provide the functionality for the audio player",
selectedFile: $content.settings.pages.audioPlayerJsFile)
selectedFile: $content.settings.pages.audioPlayerJsFile,
allowedType: .text)
FilePropertyView(
title: "3D Model Viewer File",
footer: "The JavaScript file to provide the functionality for the 3D model viewer",
selectedFile: $content.settings.pages.modelViewerJsFile)
selectedFile: $content.settings.pages.modelViewerJsFile,
allowedType: .text)
}
.padding()
}
}
}

@ -42,6 +42,7 @@ struct PostFeedSettingsView: View {
LocalizedPostFeedSettingsView(
settings: content.settings.localized(in: language))
}
.padding()
}
}
}

@ -70,5 +70,6 @@ private struct TagOverviewDetails: View {
text: $page.linkPreviewDescription,
footer: "The description to show in previews of the page")
}
.padding()
}
}