Improve video and image handling in markdown
This commit is contained in:
parent
d4ed30ad80
commit
a444c51697
@ -51,6 +51,9 @@
|
||||
E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */; };
|
||||
E2F8FA1E28A539C500632026 /* MarkdownProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */; };
|
||||
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */; };
|
||||
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */; };
|
||||
E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */; };
|
||||
E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2F8FA2728ACD84400632026 /* VideoType.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
@ -110,6 +113,9 @@
|
||||
E2D55EDE28A2AD4F00B9453E /* LinkPreviewMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewMetadata.swift; sourceTree = "<group>"; };
|
||||
E2F8FA1D28A539C500632026 /* MarkdownProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownProcessor.swift; sourceTree = "<group>"; };
|
||||
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderTemplate.swift; sourceTree = "<group>"; };
|
||||
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageImageTemplate.swift; sourceTree = "<group>"; };
|
||||
E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageVideoTemplate.swift; sourceTree = "<group>"; };
|
||||
E2F8FA2728ACD84400632026 /* VideoType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoType.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -151,6 +157,7 @@
|
||||
E22E876B289D855D00E51191 /* ThumbnailStyle.swift */,
|
||||
E22E8797289EA42C00E51191 /* FileProcessor.swift */,
|
||||
E22E8777289DA0E100E51191 /* GenerationError.swift */,
|
||||
E2F8FA2728ACD84400632026 /* VideoType.swift */,
|
||||
E22E8794289E81D700E51191 /* FileSystem.swift */,
|
||||
);
|
||||
path = WebsiteGenerator;
|
||||
@ -224,6 +231,8 @@
|
||||
E2C5A5E028A0373300102A25 /* ThumbnailTemplate.swift */,
|
||||
E2C5A5DA28A02F9000102A25 /* TopBarTemplate.swift */,
|
||||
E2F8FA1F28AB72D900632026 /* PlaceholderTemplate.swift */,
|
||||
E2F8FA2328ACD0A800632026 /* PageImageTemplate.swift */,
|
||||
E2F8FA2528ACD64500632026 /* PageVideoTemplate.swift */,
|
||||
);
|
||||
path = Elements;
|
||||
sourceTree = "<group>";
|
||||
@ -319,6 +328,7 @@
|
||||
E26555E428A2C4FA00BAF496 /* LinkPreviewMetadataProvider.swift in Sources */,
|
||||
E22E87AA289F1AEE00E51191 /* PageHeadGenerator.swift in Sources */,
|
||||
E2D55EDB28A2511D00B9453E /* OverviewSectionCleanTemplate.swift in Sources */,
|
||||
E2F8FA2828ACD84400632026 /* VideoType.swift in Sources */,
|
||||
E2D55EDF28A2AD4F00B9453E /* LinkPreviewMetadata.swift in Sources */,
|
||||
E22E876A289D84FD00E51191 /* Section+Metadata.swift in Sources */,
|
||||
E2F8FA2028AB72D900632026 /* PlaceholderTemplate.swift in Sources */,
|
||||
@ -340,6 +350,7 @@
|
||||
E2C5A5E328A037F900102A25 /* PageTemplate.swift in Sources */,
|
||||
E2C5A5DD28A036BE00102A25 /* OverviewSectionTemplate.swift in Sources */,
|
||||
E2C5A5E528A03A6500102A25 /* BackNavigationTemplate.swift in Sources */,
|
||||
E2F8FA2628ACD64500632026 /* PageVideoTemplate.swift in Sources */,
|
||||
E2C5A5DB28A02F9000102A25 /* TopBarTemplate.swift in Sources */,
|
||||
E22E87B6289FF67B00E51191 /* Metadata.swift in Sources */,
|
||||
E22E8778289DA0E100E51191 /* GenerationError.swift in Sources */,
|
||||
@ -352,6 +363,7 @@
|
||||
E22E879B289EE02F00E51191 /* Optional+Extensions.swift in Sources */,
|
||||
E22E877A289DA9F900E51191 /* Site.swift in Sources */,
|
||||
E22E87B2289F296700E51191 /* ThumbnailInfo.swift in Sources */,
|
||||
E2F8FA2428ACD0A800632026 /* PageImageTemplate.swift in Sources */,
|
||||
E22E8787289DDF4C00E51191 /* Page.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -5,6 +5,32 @@ import AppKit
|
||||
|
||||
final class FileProcessor {
|
||||
|
||||
enum MediaType {
|
||||
case image
|
||||
case video
|
||||
case file
|
||||
}
|
||||
|
||||
func mediaType(forExtension fileExtension: String) -> MediaType {
|
||||
if supportedImageExtensions[fileExtension] != nil {
|
||||
return .image
|
||||
}
|
||||
if supportedVideoExtensions.contains(fileExtension) {
|
||||
return .video
|
||||
}
|
||||
return .file
|
||||
}
|
||||
|
||||
private let supportedImageExtensions: [String : NSBitmapImageRep.FileType] = [
|
||||
"jpg" : .jpeg,
|
||||
"jpeg" : .jpeg,
|
||||
"png" : .png,
|
||||
]
|
||||
|
||||
private let supportedVideoExtensions: Set<String> = [
|
||||
"mp4", "mov"
|
||||
]
|
||||
|
||||
struct ImageOutput: Hashable {
|
||||
|
||||
let source: String
|
||||
@ -89,7 +115,10 @@ final class FileProcessor {
|
||||
throw GenerationError.missingImage(sourceUrl.path)
|
||||
}
|
||||
guard let imageSize = NSImage(contentsOfFile: sourceUrl.path)?.size else {
|
||||
throw GenerationError.failedToGenerateImage(sourceUrl.path)
|
||||
let height = image.desiredHeight.unwrapped(CGFloat.init)
|
||||
let width = CGFloat(image.width)
|
||||
return .init(width: width, height: height ?? width / 16 * 9)
|
||||
//throw GenerationError.failedToGenerateImage(sourceUrl.path)
|
||||
}
|
||||
let scaledSize = getScaledSize(of: imageSize, to: CGFloat(image.width))
|
||||
|
||||
@ -146,8 +175,10 @@ final class FileProcessor {
|
||||
return
|
||||
}
|
||||
|
||||
// Just copy SVG files
|
||||
guard destination.pathExtension.lowercased() != "svg" else {
|
||||
// Ensure that image file is supported
|
||||
let ext = destination.pathExtension.lowercased()
|
||||
guard supportedImageExtensions[ext] != nil else {
|
||||
print("Copying file \(source.path)")
|
||||
try FileSystem.copy(source, to: destination)
|
||||
return
|
||||
}
|
||||
@ -166,6 +197,7 @@ final class FileProcessor {
|
||||
private func createImage(_ destination: URL, from source: URL, with desiredWidth: CGFloat, and desiredHeight: CGFloat?) throws {
|
||||
|
||||
guard let sourceImage = NSImage(contentsOfFile: source.path) else {
|
||||
print("Failed to load image \(source.path)")
|
||||
throw GenerationError.failedToGenerateImage(source.path)
|
||||
}
|
||||
|
||||
@ -213,16 +245,21 @@ final class FileProcessor {
|
||||
}
|
||||
|
||||
private func saveImage(_ image: NSImage, atUrl url: URL) throws {
|
||||
let ext = url.pathExtension.lowercased()
|
||||
guard let type = supportedImageExtensions[ext] else {
|
||||
print("No image type for \(url.path)")
|
||||
throw GenerationError.failedToGenerateImage(url.path)
|
||||
}
|
||||
guard let tiff = image.tiffRepresentation, let tiffData = NSBitmapImageRep(data: tiff) else {
|
||||
print("Failed to get jpg data for image \(url.path)")
|
||||
print("Failed to get data for image \(url.path)")
|
||||
throw GenerationError.failedToGenerateImage(url.path)
|
||||
}
|
||||
|
||||
guard let jpgData = tiffData.representation(using: .jpeg, properties: [.compressionFactor: NSNumber(0.7)]) else {
|
||||
print("Failed to get jpg data for image \(url.path)")
|
||||
guard let data = tiffData.representation(using: type, properties: [.compressionFactor: NSNumber(0.7)]) else {
|
||||
print("Failed to get data for image \(url.path)")
|
||||
throw GenerationError.failedToGenerateImage(url.path)
|
||||
}
|
||||
try jpgData.createFolderAndWrite(to: url)
|
||||
try data.createFolderAndWrite(to: url)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
@ -3,9 +3,12 @@ import Ink
|
||||
|
||||
struct PageContentGenerator {
|
||||
|
||||
private let factory: TemplateFactory
|
||||
|
||||
private let files: FileProcessor
|
||||
|
||||
init(files: FileProcessor) {
|
||||
init(factory: TemplateFactory, files: FileProcessor) {
|
||||
self.factory = factory
|
||||
self.files = files
|
||||
}
|
||||
|
||||
@ -19,11 +22,9 @@ struct PageContentGenerator {
|
||||
var hasCodeContent = false
|
||||
|
||||
let imageModifier = Modifier(target: .images) { html, markdown in
|
||||
let result = processMarkdownImage(markdown: markdown, html: html, page: page)
|
||||
switch result {
|
||||
case .success(let content):
|
||||
return content
|
||||
case .failure(let error):
|
||||
do {
|
||||
return try processMarkdownImage(markdown: markdown, html: html, page: page)
|
||||
} catch {
|
||||
errorToThrow = error
|
||||
return ""
|
||||
}
|
||||
@ -36,9 +37,13 @@ struct PageContentGenerator {
|
||||
hasCodeContent = true
|
||||
return html
|
||||
}
|
||||
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier])
|
||||
let linkModifier = Modifier(target: .links) { html, markdown in
|
||||
#warning("Check links in markdown for (missing) files to copy")
|
||||
return html
|
||||
}
|
||||
|
||||
let parser = MarkdownParser(modifiers: [imageModifier, codeModifier, linkModifier])
|
||||
|
||||
#warning("Check links in markdown for (missing) files to copy")
|
||||
if hasCodeContent {
|
||||
#warning("Automatically add hljs hightlighting if code samples are found")
|
||||
}
|
||||
@ -50,81 +55,68 @@ struct PageContentGenerator {
|
||||
return result
|
||||
}
|
||||
|
||||
private func processMarkdownImage(markdown: Substring, html: String, page: Page) -> Result<String, Error> {
|
||||
let fileAndTitle = markdown
|
||||
.components(separatedBy: "(").last!
|
||||
.components(separatedBy: ")").first!
|
||||
private func processMarkdownImage(markdown: Substring, html: String, page: Page) throws -> String {
|
||||
// Split the markdown ![alt](file "title")
|
||||
// For images: ![left_title](file "right_title")
|
||||
// For videos: ![option...](file)
|
||||
// For files: ?
|
||||
let fileAndTitle = markdown.between("(", and: ")")
|
||||
let file = fileAndTitle.dropAfterFirst(" \"")
|
||||
let title = fileAndTitle.contains(" \"") ? fileAndTitle.between("\"", and: "\"").nonEmpty : nil
|
||||
let alt = markdown.between("[", and: "]").nonEmpty
|
||||
|
||||
let file = fileAndTitle.components(separatedBy: " \"").first! // Remove title
|
||||
let rightSubtitle: String?
|
||||
if fileAndTitle.contains(" \"") {
|
||||
rightSubtitle = fileAndTitle.dropBeforeFirst("\"").dropAfterLast("\"")
|
||||
} else {
|
||||
rightSubtitle = nil
|
||||
let fileExtension = file.lastComponentAfter(".").lowercased()
|
||||
switch files.mediaType(forExtension: fileExtension) {
|
||||
case .image:
|
||||
return try handleImage(page: page, file: file, rightTitle: title, leftTitle: alt)
|
||||
case .video:
|
||||
return try handleVideo(page: page, file: file, optionString: alt)
|
||||
case .file:
|
||||
#warning("Handle other files in markdown")
|
||||
print("[WARN] Unhandled file \(file) with extension \(fileExtension)")
|
||||
return ""
|
||||
}
|
||||
let leftSubtitle = markdown
|
||||
.components(separatedBy: "]").first!
|
||||
.components(separatedBy: "[").last!.nonEmpty
|
||||
}
|
||||
|
||||
private func handleImage(page: Page, file: String, rightTitle: String?, leftTitle: String?) throws -> String {
|
||||
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
|
||||
#warning("Specify page image width in configuration")
|
||||
let pageImageWidth = 748
|
||||
let size: NSSize
|
||||
let imagePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
do {
|
||||
size = try files.requireImage(
|
||||
source: imagePath,
|
||||
destination: imagePath,
|
||||
width: pageImageWidth,
|
||||
desiredHeight: nil,
|
||||
createDoubleVersion: true)
|
||||
} catch {
|
||||
return .failure(error)
|
||||
}
|
||||
let size = try files.requireImage(source: imagePath, destination: imagePath, width: pageImageWidth)
|
||||
|
||||
let imagePath2x = imagePath.insert("@2x", beforeLast: ".")
|
||||
let file2x = file.insert("@2x", beforeLast: ".")
|
||||
#warning("Move HTML code to single location")
|
||||
let result = articelImage(
|
||||
image: file,
|
||||
image2x: file2x,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
rightSubtitle: rightSubtitle,
|
||||
leftSubtitle: leftSubtitle)
|
||||
return .success(result)
|
||||
try files.requireImage(source: imagePath, destination: imagePath2x, width: 2 * pageImageWidth)
|
||||
|
||||
let content: [PageImageTemplate.Key : String] = [
|
||||
.image: file,
|
||||
.image2x: file2x,
|
||||
.width: "\(Int(size.width))",
|
||||
.height: "\(Int(size.height))",
|
||||
.leftText: leftTitle ?? "",
|
||||
.rightText: rightTitle ?? ""]
|
||||
return factory.image.generate(content)
|
||||
}
|
||||
|
||||
private func articelImage(image: String, image2x: String, width: CGFloat, height: CGFloat, rightSubtitle: String?, leftSubtitle: String?) -> String {
|
||||
let subtitleCode = subtitle(left: leftSubtitle, right: rightSubtitle)
|
||||
return fullImageCode(image: image, image2x: image2x, width: width, height: height, subtitle: subtitleCode)
|
||||
}
|
||||
private func handleVideo(page: Page, file: String, optionString: String?) throws -> String {
|
||||
let options: [PageVideoTemplate.VideoOption] = optionString.unwrapped { string in
|
||||
string.components(separatedBy: " ").compactMap { optionText in
|
||||
guard let optionText = optionText.trimmed.nonEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard let option = PageVideoTemplate.VideoOption(rawValue: optionText) else {
|
||||
print("[WARN] Unknown video option \(optionText) in page \(page.path)")
|
||||
return nil
|
||||
}
|
||||
return option
|
||||
}
|
||||
} ?? []
|
||||
#warning("Check page folder for alternative video versions")
|
||||
let sources: [PageVideoTemplate.VideoSource] = [(url: file, type: .mp4)]
|
||||
|
||||
private func articleImageWithoutSubtitle(image: String, image2x: String, width: CGFloat, height: CGFloat) -> String {
|
||||
"""
|
||||
<span class="image">
|
||||
<img src="\(image)" srcset="\(image2x) 2x" width="\(Int(width))" height="\(Int(height))" loading="lazy"/>
|
||||
</span>
|
||||
"""
|
||||
}
|
||||
|
||||
private func subtitle(left: String?, right: String?) -> String {
|
||||
guard left != nil || right != nil else {
|
||||
return ""
|
||||
}
|
||||
let leftCode = left.unwrapped { "<span class=\"left\">\($0)</span>" } ?? ""
|
||||
let rightCode = right.unwrapped { "<span class=\"right\">\($0)</span>" } ?? ""
|
||||
return """
|
||||
<div class="subtitle">
|
||||
\(leftCode)
|
||||
\(rightCode)
|
||||
</div>
|
||||
"""
|
||||
}
|
||||
|
||||
private func fullImageCode(image: String, image2x: String, width: CGFloat, height: CGFloat, subtitle: String) -> String {
|
||||
"""
|
||||
<span class="image">
|
||||
<img src="\(image)" srcset="\(image2x) 2x" width="\(Int(width))" height="\(Int(height))" loading="lazy"/>
|
||||
\(subtitle)
|
||||
</span>
|
||||
"""
|
||||
let filePath = page.pathRelativeToRootForContainedInputFile(file)
|
||||
files.require(file: filePath)
|
||||
return factory.video.generate(sources: sources, options: options)
|
||||
}
|
||||
}
|
||||
|
@ -55,7 +55,8 @@ struct PageGenerator {
|
||||
return factory.placeholder
|
||||
}
|
||||
print("Generated page \(page.path)")
|
||||
return try PageContentGenerator(files: files).generate(page: page, language: language, at: url)
|
||||
return try PageContentGenerator(factory: factory.factory, files: files)
|
||||
.generate(page: page, language: language, at: url)
|
||||
}
|
||||
|
||||
private func makeHead(page: Page, language: String) throws -> String {
|
||||
|
18
WebsiteGenerator/Templates/Elements/PageImageTemplate.swift
Normal file
18
WebsiteGenerator/Templates/Elements/PageImageTemplate.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct PageImageTemplate: Template {
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case image = "IMAGE"
|
||||
case image2x = "IMAGE_2X"
|
||||
case width = "WIDTH"
|
||||
case height = "HEIGHT"
|
||||
case leftText = "LEFT_TEXT"
|
||||
case rightText = "RIGHT_TEXT"
|
||||
}
|
||||
|
||||
static let templateName = "image.html"
|
||||
|
||||
let raw: String
|
||||
|
||||
}
|
37
WebsiteGenerator/Templates/Elements/PageVideoTemplate.swift
Normal file
37
WebsiteGenerator/Templates/Elements/PageVideoTemplate.swift
Normal file
@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
struct PageVideoTemplate: Template {
|
||||
|
||||
typealias VideoSource = (url: String, type: VideoType)
|
||||
|
||||
enum Key: String, CaseIterable {
|
||||
case options = "OPTIONS"
|
||||
case sources = "SOURCES"
|
||||
}
|
||||
|
||||
enum VideoOption: String {
|
||||
case controls
|
||||
case autoplay
|
||||
case muted
|
||||
case loop
|
||||
case playsinline
|
||||
case poster
|
||||
case preload
|
||||
}
|
||||
|
||||
static let templateName = "video.html"
|
||||
|
||||
let raw: String
|
||||
|
||||
func generate<T>(sources: [VideoSource], options: T) -> String where T: Sequence, T.Element == VideoOption {
|
||||
let sourcesCode = sources.map(makeSource).joined(separator: "\n")
|
||||
let optionCode = options.map { $0.rawValue }.joined(separator: " ")
|
||||
return generate([.sources: sourcesCode, .options: optionCode])
|
||||
}
|
||||
|
||||
private func makeSource(_ source: VideoSource) -> String {
|
||||
"""
|
||||
<source src="\(source.url)" type="\(source.type.htmlType)">
|
||||
"""
|
||||
}
|
||||
}
|
@ -47,6 +47,10 @@ final class TemplateFactory {
|
||||
|
||||
let page: PageTemplate
|
||||
|
||||
let image: PageImageTemplate
|
||||
|
||||
let video: PageVideoTemplate
|
||||
|
||||
// MARK: Init
|
||||
|
||||
init(templateFolder: URL) throws {
|
||||
@ -63,5 +67,7 @@ final class TemplateFactory {
|
||||
self.leftHeader = try .init(in: templateFolder)
|
||||
self.centeredHeader = try .init(in: templateFolder)
|
||||
self.page = try .init(in: templateFolder)
|
||||
self.image = try .init(in: templateFolder)
|
||||
self.video = try .init(in: templateFolder)
|
||||
}
|
||||
}
|
||||
|
12
WebsiteGenerator/VideoType.swift
Normal file
12
WebsiteGenerator/VideoType.swift
Normal file
@ -0,0 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
enum VideoType: String {
|
||||
case mp4
|
||||
|
||||
var htmlType: String {
|
||||
switch self {
|
||||
case .mp4:
|
||||
return "video/mp4"
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user