Refactor page content generators
This commit is contained in:
107
CHDataManagement/Generator/Commands/AudioPlayerCommand.swift
Normal file
107
CHDataManagement/Generator/Commands/AudioPlayerCommand.swift
Normal file
@ -0,0 +1,107 @@
|
||||
import Foundation
|
||||
|
||||
struct AudioPlayerCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .audioPlayer
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 2 else {
|
||||
invalid("Invalid audio player arguments")
|
||||
return ""
|
||||
}
|
||||
let fileId = arguments[0]
|
||||
let titleText = arguments[1]
|
||||
|
||||
guard content.isValidIdForFile(fileId) else {
|
||||
invalid("Invalid file id \(fileId) for audio player")
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, source: "Audio player song list")
|
||||
return ""
|
||||
}
|
||||
guard let data = file.dataContent() else {
|
||||
results.inaccessibleContent(file: file)
|
||||
return ""
|
||||
}
|
||||
let songs: [Song]
|
||||
do {
|
||||
songs = try JSONDecoder().decode([Song].self, from: data)
|
||||
} catch {
|
||||
results.invalidFormat(file: file, error: "Not valid JSON containing [Song]: \(error)")
|
||||
return ""
|
||||
}
|
||||
|
||||
var playlist: [AudioPlayer.PlaylistItem] = []
|
||||
var amplitude: [AmplitudeSong] = []
|
||||
|
||||
for song in songs {
|
||||
guard let image = content.file(song.cover) else {
|
||||
results.missing(file: song.cover, containedIn: file)
|
||||
continue
|
||||
}
|
||||
guard image.type.isImage else {
|
||||
results.warning("Cover '\(song.cover)' in file \(fileId) is not an image file")
|
||||
continue
|
||||
}
|
||||
|
||||
guard let audioFile = content.file(song.file) else {
|
||||
results.missing(file: song.cover, containedIn: file)
|
||||
continue
|
||||
}
|
||||
guard audioFile.type.isAudio else {
|
||||
results.warning("Song '\(song.file)' in file \(fileId) is not an audio file")
|
||||
continue
|
||||
}
|
||||
|
||||
let coverSize = 2 * content.settings.audioPlayer.playlistCoverImageSize
|
||||
let coverImage = image.imageVersion(width: coverSize, height: coverSize, type: image.type)
|
||||
let coverUrl = coverImage.outputPath
|
||||
results.require(image: coverImage)
|
||||
|
||||
let playlistItem = AudioPlayer.PlaylistItem(
|
||||
index: playlist.count,
|
||||
image: coverUrl,
|
||||
name: song.name,
|
||||
album: song.album,
|
||||
track: song.track,
|
||||
artist: song.artist)
|
||||
|
||||
let amplitudeSong = AmplitudeSong(
|
||||
name: song.name,
|
||||
artist: song.artist,
|
||||
album: song.album,
|
||||
track: "\(song.track)",
|
||||
url: audioFile.absoluteUrl,
|
||||
cover_art_url: coverUrl)
|
||||
|
||||
playlist.append(playlistItem)
|
||||
amplitude.append(amplitudeSong)
|
||||
}
|
||||
|
||||
let footerScript = AudioPlayerScript(items: amplitude).content
|
||||
results.require(footer: footerScript)
|
||||
results.require(headers: .audioPlayerCss, .audioPlayerJs)
|
||||
|
||||
results.require(icons:
|
||||
.audioPlayerClose,
|
||||
.audioPlayerPlaylist,
|
||||
.audioPlayerNext,
|
||||
.audioPlayerPrevious,
|
||||
.audioPlayerPlay,
|
||||
.audioPlayerPause
|
||||
)
|
||||
|
||||
return AudioPlayer(playingText: titleText, items: playlist).content
|
||||
}
|
||||
}
|
24
CHDataManagement/Generator/Commands/BoxCommand.swift
Normal file
24
CHDataManagement/Generator/Commands/BoxCommand.swift
Normal file
@ -0,0 +1,24 @@
|
||||
|
||||
struct BoxCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .box
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count > 1 else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
let title = arguments[0]
|
||||
let text = arguments.dropFirst().joined(separator: ";")
|
||||
return ContentBox(title: title, text: text).content
|
||||
}
|
||||
}
|
102
CHDataManagement/Generator/Commands/ButtonCommand.swift
Normal file
102
CHDataManagement/Generator/Commands/ButtonCommand.swift
Normal file
@ -0,0 +1,102 @@
|
||||
|
||||
struct ButtonCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .buttons
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
Types:
|
||||
- Download: `download=<fileId>,<text>,<download-filename?>`
|
||||
- External link: `external=<url>,<text>`
|
||||
- Git: `git=<url>,<text>`
|
||||
- Play: `play-circle=<text>,<click-action>`
|
||||
*/
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
let buttons = arguments.compactMap { convert(button: $0, markdown: markdown) }
|
||||
return ContentButtons(items: buttons).content
|
||||
}
|
||||
|
||||
private func convert(button: String, markdown: Substring) -> ContentButtons.Item? {
|
||||
guard let type = PageIcon(rawValue: button.dropAfterFirst("=").trimmed) else {
|
||||
invalid(markdown)
|
||||
return nil
|
||||
}
|
||||
let parts = button.dropBeforeFirst("=").components(separatedBy: ",").map { $0.trimmed }
|
||||
switch type {
|
||||
case .buttonDownload:
|
||||
return download(arguments: parts, markdown: markdown)
|
||||
case .buttonGitLink:
|
||||
return link(icon: .buttonGitLink, arguments: parts, markdown: markdown)
|
||||
case .buttonExternalLink:
|
||||
return link(icon: .buttonExternalLink, arguments: parts, markdown: markdown)
|
||||
case .buttonPlay:
|
||||
return play(arguments: parts, markdown: markdown)
|
||||
default:
|
||||
invalid(markdown)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func download(arguments: [String], markdown: Substring) -> ContentButtons.Item? {
|
||||
guard (2...3).contains(arguments.count) else {
|
||||
invalid(markdown)
|
||||
return nil
|
||||
}
|
||||
let fileId = arguments[0].trimmed
|
||||
let title = arguments[1].trimmed
|
||||
let downloadName = arguments.count > 2 ? arguments[2].trimmed : nil
|
||||
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, source: "Download button")
|
||||
return nil
|
||||
}
|
||||
results.require(file: file)
|
||||
results.require(icon: .buttonDownload)
|
||||
return ContentButtons.Item(
|
||||
icon: .buttonDownload,
|
||||
filePath: file.absoluteUrl,
|
||||
text: title,
|
||||
downloadFileName: downloadName)
|
||||
}
|
||||
|
||||
private func link(icon: PageIcon, arguments: [String], markdown: Substring) -> ContentButtons.Item? {
|
||||
guard arguments.count == 2 else {
|
||||
results.invalid(command: .buttons, markdown)
|
||||
return nil
|
||||
}
|
||||
let rawUrl = arguments[0].trimmed
|
||||
guard let url = rawUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
|
||||
results.invalid(command: .buttons, markdown)
|
||||
return nil
|
||||
}
|
||||
|
||||
results.externalLink(to: rawUrl)
|
||||
results.require(icon: icon)
|
||||
|
||||
let title = arguments[1].trimmed
|
||||
|
||||
return .init(icon: icon, filePath: url, text: title)
|
||||
}
|
||||
|
||||
private func play(arguments: [String], markdown: Substring) -> ContentButtons.Item? {
|
||||
guard arguments.count == 2 else {
|
||||
results.invalid(command: .buttons, markdown)
|
||||
return nil
|
||||
}
|
||||
let text = arguments[0].trimmed
|
||||
let event = arguments[1].trimmed
|
||||
|
||||
results.require(icon: .buttonPlay)
|
||||
|
||||
return .init(icon: .buttonPlay, filePath: nil, text: text, onClickText: event)
|
||||
}
|
||||
}
|
18
CHDataManagement/Generator/Commands/CommandProcessor.swift
Normal file
18
CHDataManagement/Generator/Commands/CommandProcessor.swift
Normal file
@ -0,0 +1,18 @@
|
||||
|
||||
protocol CommandProcessor {
|
||||
|
||||
static var commandType: CommandType { get }
|
||||
|
||||
var results: PageGenerationResults { get }
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage)
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String
|
||||
}
|
||||
|
||||
extension CommandProcessor {
|
||||
|
||||
func invalid(_ markdown: Substring) {
|
||||
results.invalid(command: Self.commandType, markdown)
|
||||
}
|
||||
}
|
92
CHDataManagement/Generator/Commands/CommandType.swift
Normal file
92
CHDataManagement/Generator/Commands/CommandType.swift
Normal file
@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
|
||||
/**
|
||||
A string key used in markdown to indicate special elements
|
||||
*/
|
||||
enum CommandType: String {
|
||||
|
||||
/// An image
|
||||
/// Format: ``
|
||||
case labels
|
||||
|
||||
/// A video
|
||||
/// Format: ``
|
||||
Types:
|
||||
- Download: `download=<fileId>,<text>,<download-filename?>`
|
||||
- External link: `external=<url>,<text>`
|
||||
- Git: `git=<url>,<text>`
|
||||
- Play: `play-circle=<text>,<click-action>`
|
||||
*/
|
||||
case buttons
|
||||
|
||||
/// A box with a title and content
|
||||
/// Format: ``
|
||||
case box
|
||||
|
||||
/// A 3D model to display
|
||||
/// Format: ``
|
||||
case model
|
||||
|
||||
/// A pretty link to another page on the site.
|
||||
/// Format: ``
|
||||
case pageLink = "page"
|
||||
|
||||
/// A pretty link to a tag list on the site.
|
||||
/// Format: ``
|
||||
case tagLink = "tag"
|
||||
|
||||
/// Additional HTML code included verbatim into the page.
|
||||
/// Format: ``
|
||||
case includedHtml = "html"
|
||||
|
||||
/// SVG Image showing only a part of the image
|
||||
/// Format ``
|
||||
case svg
|
||||
|
||||
/// A player to play audio files
|
||||
/// Format: ``
|
||||
case audioPlayer = "audio-player"
|
||||
|
||||
/// Add svg icons to the page for use in html components
|
||||
/// Format: ``
|
||||
case icons
|
||||
|
||||
/**
|
||||
Create an image comparison with a slider.
|
||||
Format: ``
|
||||
*/
|
||||
case imageCompare = "compare"
|
||||
|
||||
|
||||
var processor: CommandProcessor.Type {
|
||||
switch self {
|
||||
case .image: return ImageCommand.self
|
||||
case .labels: return LabelsCommand.self
|
||||
case .video: return VideoCommand.self
|
||||
case .buttons: return ButtonCommand.self
|
||||
case .box: return BoxCommand.self
|
||||
case .model: return ModelCommand.self
|
||||
case .pageLink: return PageLinkCommand.self
|
||||
case .tagLink: return TagLinkCommand.self
|
||||
case .includedHtml: return HtmlCommand.self
|
||||
case .svg: return SvgCommand.self
|
||||
case .audioPlayer: return AudioPlayerCommand.self
|
||||
case .icons: return IconCommand.self
|
||||
case .imageCompare: return ImageCompareCommand.self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CommandType: CaseIterable {
|
||||
|
||||
}
|
154
CHDataManagement/Generator/Commands/HtmlCommand.swift
Normal file
154
CHDataManagement/Generator/Commands/HtmlCommand.swift
Normal file
@ -0,0 +1,154 @@
|
||||
import SwiftSoup
|
||||
|
||||
/**
|
||||
Handles both inline HTML and the external HTML command
|
||||
*/
|
||||
struct HtmlCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .includedHtml
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let content: Content
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
||||
/**
|
||||
Handle the HTML command
|
||||
|
||||
Format: ``
|
||||
*/
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 1 else {
|
||||
invalid(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
|
||||
}
|
||||
checkSourceSetAttributes(sources: sources)
|
||||
checkSourceAttributes(sources: sources)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
24
CHDataManagement/Generator/Commands/IconCommand.swift
Normal file
24
CHDataManagement/Generator/Commands/IconCommand.swift
Normal file
@ -0,0 +1,24 @@
|
||||
|
||||
struct IconCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .icons
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.results = results
|
||||
}
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
var icons = [PageIcon]()
|
||||
for argument in arguments {
|
||||
guard let icon = PageIcon(rawValue: argument) else {
|
||||
invalid(markdown)
|
||||
continue
|
||||
}
|
||||
icons.append(icon)
|
||||
}
|
||||
results.require(icons: icons)
|
||||
return ""
|
||||
}
|
||||
}
|
66
CHDataManagement/Generator/Commands/ImageCommand.swift
Normal file
66
CHDataManagement/Generator/Commands/ImageCommand.swift
Normal file
@ -0,0 +1,66 @@
|
||||
|
||||
struct ImageCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .image
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
private var thumbnailWidth: Int {
|
||||
content.settings.pages.contentWidth
|
||||
}
|
||||
|
||||
private var largeImageWidth: Int {
|
||||
content.settings.pages.largeImageWidth
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ` -> String {
|
||||
guard (1...2).contains(arguments.count) else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
let imageId = arguments[0]
|
||||
|
||||
guard let image = content.image(imageId) else {
|
||||
results.missing(file: imageId, source: "Image command")
|
||||
return ""
|
||||
}
|
||||
results.used(file: image)
|
||||
|
||||
if image.type == .svg || image.type == .gif {
|
||||
return simple(image: image)
|
||||
}
|
||||
|
||||
let thumbnail = image.imageSet(width: thumbnailWidth, height: thumbnailWidth, language: language)
|
||||
results.require(imageSet: thumbnail)
|
||||
|
||||
let largeImage = image.imageSet(width: largeImageWidth, height: largeImageWidth, language: language)
|
||||
results.require(imageSet: largeImage)
|
||||
|
||||
let caption = arguments.count == 2 ? arguments[1] : nil
|
||||
return PageImage(
|
||||
imageId: imageId.replacingOccurrences(of: ".", with: "-"),
|
||||
thumbnail: thumbnail,
|
||||
largeImage: largeImage,
|
||||
caption: caption).content
|
||||
}
|
||||
|
||||
private func simple(image: FileResource) -> String {
|
||||
results.require(file: image)
|
||||
let path = image.absoluteUrl
|
||||
let altText = image.localized(in: language)
|
||||
return SimpleImage(imagePath: path, altText: altText).content
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
|
||||
struct ImageCompareCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .imageCompare
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 2 else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
let leftImageId = arguments[0]
|
||||
let rightImageId = arguments[1]
|
||||
guard let leftImage = content.image(leftImageId) else {
|
||||
results.missing(file: leftImageId, source: "Image compare")
|
||||
return ""
|
||||
}
|
||||
guard let rightImage = content.image(rightImageId) else {
|
||||
results.missing(file: rightImageId, source: "Image compare")
|
||||
return ""
|
||||
}
|
||||
|
||||
let size = content.settings.pages.contentWidth
|
||||
|
||||
let leftImageSet = leftImage.imageSet(
|
||||
width: size, height: size,
|
||||
language: language,
|
||||
extraAttributes: ImageCompare.extraAttributes)
|
||||
|
||||
let rightImageSet = rightImage.imageSet(
|
||||
width: size, height: size,
|
||||
language: language,
|
||||
extraAttributes: ImageCompare.extraAttributes)
|
||||
|
||||
results.require(imageSet: leftImageSet)
|
||||
results.require(imageSet: rightImageSet)
|
||||
results.require(icon: ImageCompare.requiredIcon)
|
||||
results.require(headers: .imageCompareJs, .imageCompareCss)
|
||||
|
||||
return ImageCompare(left: leftImageSet, right: rightImageSet).content
|
||||
}
|
||||
}
|
31
CHDataManagement/Generator/Commands/LabelsCommand.swift
Normal file
31
CHDataManagement/Generator/Commands/LabelsCommand.swift
Normal file
@ -0,0 +1,31 @@
|
||||
|
||||
struct LabelsCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .labels
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
let labels: [ContentLabel] = arguments.compactMap { arg in
|
||||
let parts = arg.components(separatedBy: "=")
|
||||
guard parts.count == 2 else {
|
||||
invalid(markdown)
|
||||
return nil
|
||||
}
|
||||
guard let icon = PageIcon(rawValue: parts[0].trimmed) else {
|
||||
invalid(markdown)
|
||||
return nil
|
||||
}
|
||||
results.require(icon: icon)
|
||||
return .init(icon: icon, value: parts[1])
|
||||
}
|
||||
return ContentLabels(labels: labels).content
|
||||
}
|
||||
}
|
44
CHDataManagement/Generator/Commands/ModelCommand.swift
Normal file
44
CHDataManagement/Generator/Commands/ModelCommand.swift
Normal file
@ -0,0 +1,44 @@
|
||||
|
||||
struct ModelCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .model
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 1 else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
let fileId = arguments[0]
|
||||
guard fileId.hasSuffix(".glb") else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, source: "Model command")
|
||||
return ""
|
||||
}
|
||||
results.require(file: file)
|
||||
results.require(header: .modelViewer)
|
||||
|
||||
let description = file.localized(in: language)
|
||||
return ModelViewer(file: file.absoluteUrl, description: description).content
|
||||
}
|
||||
|
||||
|
||||
}
|
61
CHDataManagement/Generator/Commands/PageLinkCommand.swift
Normal file
61
CHDataManagement/Generator/Commands/PageLinkCommand.swift
Normal file
@ -0,0 +1,61 @@
|
||||
|
||||
struct PageLinkCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .pageLink
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 1 else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
let pageId = arguments[0]
|
||||
|
||||
guard let page = content.page(pageId) else {
|
||||
results.missing(page: pageId, source: "Page link command")
|
||||
return ""
|
||||
}
|
||||
guard !page.isDraft else {
|
||||
// Prevent linking to unpublished content
|
||||
return ""
|
||||
}
|
||||
|
||||
results.linked(to: page)
|
||||
|
||||
let localized = page.localized(in: language)
|
||||
let url = page.absoluteUrl(in: language)
|
||||
let title = localized.linkPreviewTitle ?? localized.title
|
||||
let description = localized.linkPreviewDescription ?? ""
|
||||
let image = makePageImage(item: localized)
|
||||
|
||||
return RelatedPageLink(
|
||||
title: title,
|
||||
description: description,
|
||||
url: url,
|
||||
image: image)
|
||||
.content
|
||||
}
|
||||
|
||||
private func makePageImage(item: LinkPreviewItem) -> ImageSet? {
|
||||
item.linkPreviewImage.map { image in
|
||||
let size = content.settings.pages.pageLinkImageSize
|
||||
let imageSet = image.imageSet(width: size, height: size, language: language)
|
||||
results.require(imageSet: imageSet)
|
||||
return imageSet
|
||||
}
|
||||
}
|
||||
}
|
52
CHDataManagement/Generator/Commands/SvgCommand.swift
Normal file
52
CHDataManagement/Generator/Commands/SvgCommand.swift
Normal file
@ -0,0 +1,52 @@
|
||||
|
||||
struct SvgCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .svg
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 5 else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
guard let x = Int(arguments[1]),
|
||||
let y = Int(arguments[2]),
|
||||
let partWidth = Int(arguments[3]),
|
||||
let partHeight = Int(arguments[4]) else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
let imageId = arguments[0]
|
||||
|
||||
guard let image = content.image(imageId) else {
|
||||
results.missing(file: imageId, source: "SVG command")
|
||||
return ""
|
||||
}
|
||||
guard image.type == .svg else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
return PartialSvgImage(
|
||||
imagePath: image.absoluteUrl,
|
||||
altText: image.localized(in: language),
|
||||
x: x,
|
||||
y: y,
|
||||
width: partWidth,
|
||||
height: partHeight)
|
||||
.content
|
||||
}
|
||||
}
|
55
CHDataManagement/Generator/Commands/TagLinkCommand.swift
Normal file
55
CHDataManagement/Generator/Commands/TagLinkCommand.swift
Normal file
@ -0,0 +1,55 @@
|
||||
|
||||
struct TagLinkCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .tagLink
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
let language: ContentLanguage
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
self.language = language
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ``
|
||||
*/
|
||||
func process(_ arguments: [String], markdown: Substring) -> String {
|
||||
guard arguments.count == 1 else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
let tagId = arguments[0]
|
||||
|
||||
guard let tag = content.tag(tagId) else {
|
||||
results.missing(tag: tagId, source: "Tag link command")
|
||||
return ""
|
||||
}
|
||||
|
||||
let localized = tag.localized(in: language)
|
||||
let url = tag.absoluteUrl(in: language)
|
||||
let title = localized.name
|
||||
let description = localized.linkPreviewDescription ?? ""
|
||||
let image = makePageImage(item: localized)
|
||||
|
||||
return RelatedPageLink(
|
||||
title: title,
|
||||
description: description,
|
||||
url: url,
|
||||
image: image)
|
||||
.content
|
||||
}
|
||||
|
||||
private func makePageImage(item: LinkPreviewItem) -> ImageSet? {
|
||||
item.linkPreviewImage.map { image in
|
||||
let size = content.settings.pages.pageLinkImageSize
|
||||
let imageSet = image.imageSet(width: size, height: size, language: language)
|
||||
results.require(imageSet: imageSet)
|
||||
return imageSet
|
||||
}
|
||||
}
|
||||
}
|
129
CHDataManagement/Generator/Commands/VideoCommand+Option.swift
Normal file
129
CHDataManagement/Generator/Commands/VideoCommand+Option.swift
Normal file
@ -0,0 +1,129 @@
|
||||
|
||||
extension VideoCommand {
|
||||
|
||||
/// HTML video options
|
||||
enum Option {
|
||||
|
||||
/// Specifies that video controls should be displayed (such as a play/pause button etc).
|
||||
case controls
|
||||
|
||||
/// Specifies that the video will start playing as soon as it is ready
|
||||
case autoplay
|
||||
|
||||
/// Specifies that the video will start over again, every time it is finished
|
||||
case loop
|
||||
|
||||
/// Specifies that the audio output of the video should be muted
|
||||
case muted
|
||||
|
||||
/// Mobile browsers will play the video right where it is instead of the default, which is to open it up fullscreen while it plays
|
||||
case playsinline
|
||||
|
||||
/// Sets the height of the video player
|
||||
case height(Int)
|
||||
|
||||
/// Sets the width of the video player
|
||||
case width(Int)
|
||||
|
||||
/// Specifies if and how the author thinks the video should be loaded when the page loads
|
||||
case preload(Preload)
|
||||
|
||||
/// Specifies an image to be shown while the video is downloading, or until the user hits the play button
|
||||
case poster(image: String)
|
||||
|
||||
/// Specifies the URL of the video file
|
||||
case src(String)
|
||||
|
||||
init?(rawValue: String) {
|
||||
switch rawValue {
|
||||
case "controls":
|
||||
self = .controls
|
||||
return
|
||||
case "autoplay":
|
||||
self = .autoplay
|
||||
return
|
||||
case "muted":
|
||||
self = .muted
|
||||
return
|
||||
case "loop":
|
||||
self = .loop
|
||||
return
|
||||
case "playsinline":
|
||||
self = .playsinline
|
||||
return
|
||||
default: break
|
||||
}
|
||||
|
||||
let parts = rawValue.components(separatedBy: "=")
|
||||
guard parts.count == 2 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let optionName = parts[0]
|
||||
let value = parts[1].removingSurroundingQuotes
|
||||
|
||||
switch optionName {
|
||||
case "height":
|
||||
guard let height = Int(value) else {
|
||||
return nil
|
||||
}
|
||||
self = .height(height)
|
||||
case "width":
|
||||
guard let width = Int(value) else {
|
||||
return nil
|
||||
}
|
||||
self = .width(width)
|
||||
case "preload":
|
||||
guard let preloadOption = Preload(rawValue: value) else {
|
||||
return nil
|
||||
}
|
||||
self = .preload(preloadOption)
|
||||
case "poster":
|
||||
self = .poster(image: value)
|
||||
case "src":
|
||||
self = .src(value)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var rawValue: String {
|
||||
switch self {
|
||||
case .controls: return "controls"
|
||||
case .autoplay: return "autoplay"
|
||||
case .muted: return "muted"
|
||||
case .loop: return "loop"
|
||||
case .playsinline: return "playsinline"
|
||||
case .height(let height): return "height='\(height)'"
|
||||
case .width(let width): return "width='\(width)'"
|
||||
case .preload(let option): return "preload='\(option)'"
|
||||
case .poster(let image): return "poster='\(image)'"
|
||||
case .src(let url): return "src='\(url)'"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension VideoCommand.Option {
|
||||
|
||||
/**
|
||||
The `preload` attribute specifies if and how the author thinks that the video should be loaded when the page loads.
|
||||
|
||||
The `preload` attribute allows the author to provide a hint to the browser about what he/she thinks will lead to the best user experience.
|
||||
This attribute may be ignored in some instances.
|
||||
|
||||
Note: The `preload` attribute is ignored if `autoplay` is present.
|
||||
*/
|
||||
enum Preload: String {
|
||||
|
||||
/// The author thinks that the browser should load the entire video when the page loads
|
||||
case auto
|
||||
|
||||
/// The author thinks that the browser should load only metadata when the page loads
|
||||
case metadata
|
||||
|
||||
/// The author thinks that the browser should NOT load the video when the page loads
|
||||
case none
|
||||
}
|
||||
}
|
78
CHDataManagement/Generator/Commands/VideoCommand.swift
Normal file
78
CHDataManagement/Generator/Commands/VideoCommand.swift
Normal file
@ -0,0 +1,78 @@
|
||||
|
||||
struct VideoCommand: CommandProcessor {
|
||||
|
||||
static let commandType: CommandType = .video
|
||||
|
||||
let content: Content
|
||||
|
||||
let results: PageGenerationResults
|
||||
|
||||
init(content: Content, results: PageGenerationResults, language: ContentLanguage) {
|
||||
self.content = content
|
||||
self.results = results
|
||||
}
|
||||
|
||||
/**
|
||||
Format: ` -> String {
|
||||
guard arguments.count >= 1 else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
let fileId = arguments[0].trimmed
|
||||
|
||||
let options = arguments.dropFirst().compactMap { convertVideoOption($0, markdown: markdown) }
|
||||
|
||||
guard let file = content.file(fileId) else {
|
||||
results.missing(file: fileId, source: "Video command")
|
||||
return ""
|
||||
}
|
||||
results.require(file: file)
|
||||
|
||||
guard let videoType = file.type.htmlType else {
|
||||
invalid(markdown)
|
||||
return ""
|
||||
}
|
||||
|
||||
return ContentPageVideo(
|
||||
filePath: file.absoluteUrl,
|
||||
videoType: videoType,
|
||||
options: options)
|
||||
.content
|
||||
}
|
||||
|
||||
private func convertVideoOption(_ videoOption: String, markdown: Substring) -> Option? {
|
||||
guard let optionText = videoOption.trimmed.nonEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard let option = Option(rawValue: optionText) else {
|
||||
invalid(markdown)
|
||||
return nil
|
||||
}
|
||||
switch option {
|
||||
case .poster(let imageId):
|
||||
if let image = content.image(imageId) {
|
||||
let width = 2 * content.settings.pages.contentWidth
|
||||
let version = image.imageVersion(width: width, height: width, type: .jpg)
|
||||
results.require(image: version)
|
||||
return .poster(image: version.outputPath)
|
||||
} else {
|
||||
results.missing(file: imageId, source: "Video command poster")
|
||||
return nil // Image file not present, so skip the option
|
||||
}
|
||||
case .src(let videoId):
|
||||
if let video = content.video(videoId) {
|
||||
results.used(file: video)
|
||||
let link = video.absoluteUrl
|
||||
return .src(link)
|
||||
} else {
|
||||
results.missing(file: videoId, source: "Video command source")
|
||||
return nil // Video file not present, so skip the option
|
||||
}
|
||||
default:
|
||||
return option
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user