import Foundation import Ink import Splash typealias VideoSource = (url: String, type: VideoFileType) final class PageContentParser { private let pageLinkMarker = "page:" private let tagLinkMarker = "tag:" private static let codeHighlightFooter = "" private let swift = SyntaxHighlighter(format: HTMLOutputFormat()) let results = PageGenerationResults() private let content: Content let language: ContentLanguage var largeImageWidth: Int { content.settings.pages.largeImageWidth } var thumbnailWidth: Int { content.settings.pages.contentWidth } init(content: Content, language: ContentLanguage) { self.content = content self.language = language } func requestImages(_ generator: ImageGenerator) { for request in results.imagesToGenerate { generator.generateImageSet( for: request.image.id, maxWidth: CGFloat(request.size), maxHeight: CGFloat(request.size)) } } func reset() { results.reset() } func generatePage(from content: String) -> String { reset() let parser = MarkdownParser(modifiers: [ Modifier(target: .images, closure: processMarkdownImage), Modifier(target: .codeBlocks, closure: handleCode), Modifier(target: .links, closure: handleLink), Modifier(target: .html, closure: handleHTML), 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.requiredHeaders.insert(.codeHightlighting) results.requiredFooters.insert(PageContentParser.codeHighlightFooter) return html // Just use normal code highlighting } // Highlight swift code using Splash let code = markdown.between("```swift", and: "```").trimmed return "
" + swift.highlight(code) + "
" } private func handleLink(html: String, markdown: Substring) -> String { let file = markdown.between("(", and: ")") if file.hasPrefix(pageLinkMarker) { return handlePageLink(file: file, html: html, markdown: markdown) } if file.hasPrefix(tagLinkMarker) { return handleTagLink(file: file, html: html, markdown: markdown) } #warning("Check existence of linked file") return html } private func handlePageLink(file: String, html: String, markdown: Substring) -> String { // Retain links pointing to elements within a page let textToChange = file.dropAfterFirst("#") let pageId = textToChange.replacingOccurrences(of: pageLinkMarker, with: "") guard let page = content.page(pageId) else { results.missingPages.insert(pageId) // Remove link since the page can't be found return markdown.between("[", and: "]") } results.linkedPages.insert(page) let pagePath = content.absoluteUrlToPage(page, language: language) return html.replacingOccurrences(of: textToChange, with: pagePath) } private func handleTagLink(file: String, html: String, markdown: Substring) -> String { // Retain links pointing to elements within a page let textToChange = file.dropAfterFirst("#") let tagId = textToChange.replacingOccurrences(of: tagLinkMarker, with: "") guard let tag = content.tag(tagId) else { results.missingTags.insert(tagId) // Remove link since the tag can't be found return markdown.between("[", and: "]") } results.linkedTags.insert(tag) let tagPath = content.absoluteUrlToTag(tag, language: language) return html.replacingOccurrences(of: textToChange, with: tagPath) } private func handleHTML(html: String, markdown: Substring) -> String { #warning("Check HTML code in markdown for required resources") // Things to check: return html } /** Modify headlines by extracting an id from the headline and adding it into the html element Format: ###<id> The id is created by lowercasing the string, removing all special characters, and replacing spaces with scores */ private func handleHeadlines(html: String, markdown: Substring) -> String { let id = markdown .last(after: "#") .trimmed .filter { $0.isNumber || $0.isLetter || $0 == " " } .lowercased() .components(separatedBy: " ") .filter { $0 != "" } .joined(separator: "-") let parts = html.components(separatedBy: ">") return parts[0] + " id=\"\(id)\">" + parts.dropFirst().joined(separator: ">") } private func percentDecoded(_ string: String) -> String { guard let decoded = string.removingPercentEncoding else { print("Invalid string: \(string)") return string } return decoded } private func processMarkdownImage(html: String, markdown: Substring) -> String { // let argumentList = percentDecoded(markdown.between(first: "](", andLast: ")")) let arguments = argumentList.components(separatedBy: ";") let rawCommand = percentDecoded(markdown.between("![", and: "]").trimmed) guard rawCommand != "" else { return handleImage(arguments) } guard let command = ShorthandMarkdownKey(rawValue: rawCommand) else { // Treat unknown commands as normal links results.unknownCommands.append(rawCommand) return html } switch command { case .image: return handleImage(arguments) case .hikingStatistics: return handleHikingStatistics(arguments) case .downloadButtons: return handleDownloadButtons(arguments) case .video: return handleVideo(arguments) case .externalLink: return handleExternalButtons(arguments) case .gitLink: return handleGitButtons(arguments) case .pageLink: return handlePageLink(arguments) case .includedHtml: return handleExternalHtml(arguments) case .box: return handleSimpleBox(arguments) case .model: return handleModel(arguments) case .svg: return handleSvg(arguments) default: results.unknownCommands.append(command.rawValue) return "" } } /** Format: `[image](<imageId>;<caption?>]` */ private func handleImage(_ arguments: [String]) -> String { guard (1...2).contains(arguments.count) else { results.invalidCommandArguments.append((.image , arguments)) return "" } let imageId = arguments[0] guard let image = content.image(imageId) else { results.missingFiles.insert(imageId) return "" } results.files.insert(image) let caption = arguments.count == 2 ? arguments[1] : nil let altText = image.getDescription(for: language) let path = content.absoluteUrlToFile(image) guard !image.type.isSvg else { return SvgImage(imagePath: path, altText: altText).content } let thumbnail = FeedEntryData.Image( rawImagePath: path, width: thumbnailWidth, height: thumbnailWidth, altText: altText) results.imagesToGenerate.insert(.init(size: thumbnailWidth, image: image)) let largeImage = FeedEntryData.Image( rawImagePath: path, width: largeImageWidth, height: largeImageWidth, altText: altText) results.imagesToGenerate.insert(.init(size: largeImageWidth, image: image)) return PageImage( imageId: imageId.replacingOccurrences(of: ".", with: "-"), thumbnail: thumbnail, largeImage: largeImage, caption: caption).content } /** Format: `![hiking-stats](<time>;<elevation-up>;<elevation-down>;<distance>;<calories>)` */ private func handleHikingStatistics(_ arguments: [String]) -> String { #warning("Make statistics more generic using key-value pairs") guard (1...5).contains(arguments.count) else { results.invalidCommandArguments.append((.hikingStatistics, arguments)) return "" } let time = arguments[0].trimmed let elevationUp = arguments.count > 1 ? arguments[1].trimmed : nil let elevationDown = arguments.count > 2 ? arguments[2].trimmed : nil let distance = arguments.count > 3 ? arguments[3].trimmed : nil let calories = arguments.count > 4 ? arguments[4].trimmed : nil return HikingStatistics( time: time, elevationUp: elevationUp, elevationDown: elevationDown, distance: distance, calories: calories) .content } /** Format: `![download](<<fileId>,<text>,<download-filename?>;...)` */ private func handleDownloadButtons(_ arguments: [String]) -> String { let buttons = arguments.compactMap(convertButton) return ContentButtons(items: buttons).content } private func convertButton(definition button: String) -> ContentButtons.Item? { let parts = button.components(separatedBy: ",") guard (2...3).contains(parts.count) else { results.invalidCommandArguments.append((.downloadButtons, parts)) return nil } let fileId = parts[0].trimmed let title = parts[1].trimmed let downloadName = parts.count > 2 ? parts[2].trimmed : nil guard let file = content.file(id: fileId) else { results.missingFiles.insert(fileId) return nil } results.files.insert(file) let filePath = content.absoluteUrlToFile(file) return ContentButtons.Item(icon: .download, filePath: filePath, text: title, downloadFileName: downloadName) } /** Format: `![video](<fileId>;<option1...>]` */ private func handleVideo(_ arguments: [String]) -> String { guard arguments.count >= 1 else { results.invalidCommandArguments.append((.video, arguments)) return "" } let fileId = arguments[0].trimmed let options = arguments.dropFirst().compactMap(convertVideoOption) guard let file = content.file(id: fileId) else { results.missingFiles.insert(fileId) return "" } results.files.insert(file) guard let videoType = file.type.videoType?.htmlType else { results.invalidCommandArguments.append((.video, arguments)) return "" } let filePath = content.absoluteUrlToFile(file) return ContentPageVideo( filePath: filePath, videoType: videoType, options: options) .content } private func convertVideoOption(_ videoOption: String) -> VideoOption? { guard let optionText = videoOption.trimmed.nonEmpty else { return nil } guard let option = VideoOption(rawValue: optionText) else { results.invalidCommandArguments.append((.video, [optionText])) return nil } if case let .poster(imageId) = option { if let image = content.image(imageId) { results.files.insert(image) let link = content.absoluteUrlToFile(image) let width = 2*thumbnailWidth let fullLink = WebsiteImage.imagePath(source: link, width: width, height: width) return .poster(image: fullLink) } else { results.missingFiles.insert(imageId) return nil // Image file not present, so skip the option } } if case let .src(videoId) = option { if let video = content.video(videoId) { results.files.insert(video) let link = content.absoluteUrlToFile(video) // TODO: Set correct video path? return .src(link) } else { results.missingFiles.insert(videoId) return nil // Video file not present, so skip the option } } return option } private func handleExternalButtons(_ arguments: [String]) -> String { // ![external](<<url>;<text>...> handleButtons(icon: .externalLink, arguments: arguments) } private func handleGitButtons(_ arguments: [String]) -> String { // ![git](<<url>;<text>...> handleButtons(icon: .gitLink, arguments: arguments) } private func handleButtons(icon: PageIcon, arguments: [String]) -> String { guard arguments.count >= 1 else { results.invalidCommandArguments.append((.externalLink, arguments)) return "" } let buttons: [ContentButtons.Item] = arguments.compactMap { button in let parts = button.components(separatedBy: ",") guard parts.count == 2 else { results.invalidCommandArguments.append((.externalLink, parts)) return nil } let rawUrl = parts[0].trimmed guard let url = rawUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { results.invalidCommandArguments.append((.externalLink, parts)) return nil } let title = parts[1].trimmed return .init( icon: icon, filePath: url, text: title) } return ContentButtons(items: buttons).content } /** Format: `![html](<fileId>)` */ private func handleExternalHtml(_ arguments: [String]) -> String { guard arguments.count == 1 else { results.invalidCommandArguments.append((.includedHtml, arguments)) return "" } let fileId = arguments[0] guard let file = content.file(id: fileId) else { results.missingFiles.insert(fileId) return "" } return file.textContent() } /** Format: `![box](<title>;<body>)` */ private func handleSimpleBox(_ arguments: [String]) -> String { guard arguments.count > 1 else { results.invalidCommandArguments.append((.box, arguments)) return "" } let title = arguments[0] let text = arguments.dropFirst().joined(separator: ";") return ContentBox(title: title, text: text).content } /** Format: `![page](<pageId>)` */ private func handlePageLink(_ arguments: [String]) -> String { guard arguments.count == 1 else { results.invalidCommandArguments.append((.pageLink, arguments)) return "" } let pageId = arguments[0] guard let page = content.page(pageId) else { results.missingPages.insert(pageId) return "" } guard !page.isDraft else { // Prevent linking to unpublished content return "" } let localized = page.localized(in: language) let url = content.absoluteUrlToPage(page, language: language) let title = localized.linkPreviewTitle ?? localized.title let description = localized.linkPreviewDescription ?? "" let image = localized.linkPreviewImage.map { image in let size = content.settings.pages.pageLinkImageSize results.files.insert(image) results.imagesToGenerate.insert(.init(size: size, image: image)) return RelatedPageLink.Image( url: content.absoluteUrlToFile(image), description: image.getDescription(for: language), size: size) } return RelatedPageLink( title: title, description: description, url: url, image: image) .content } /** Format: `![model](<file>)` */ private func handleModel(_ arguments: [String]) -> String { guard arguments.count == 1 else { results.invalidCommandArguments.append((.model, arguments)) return "" } let fileId = arguments[0] guard fileId.hasSuffix(".glb") else { results.invalidCommandArguments.append((.model, ["\(fileId) is not a .glb file"])) return "" } guard let file = content.file(id: fileId) else { results.missingFiles.insert(fileId) return "" } results.files.insert(file) results.requiredHeaders.insert(.modelViewer) let path = content.absoluteUrlToFile(file) let description = file.getDescription(for: language) return ModelViewer(file: path, description: description).content } private func handleSvg(_ arguments: [String]) -> String { guard arguments.count == 5 else { results.invalidCommandArguments.append((.svg, arguments)) return "" } guard let x = Int(arguments[1]), let y = Int(arguments[2]), let partWidth = Int(arguments[3]), let partHeight = Int(arguments[4]) else { results.invalidCommandArguments.append((.svg, arguments)) return "" } let imageId = arguments[0] guard let image = content.image(imageId) else { results.missingFiles.insert(imageId) return "" } guard case .image(let imageType) = image.type, imageType == .svg else { results.invalidCommandArguments.append((.svg, arguments)) return "" } let path = content.absoluteUrlToFile(image) return PartialSvgImage( imagePath: path, altText: image.getDescription(for: language), x: x, y: y, width: partWidth, height: partHeight) .content } } /* private func handleGif(file: String, altText: String) -> String { let imagePath = page.pathRelativeToRootForContainedInputFile(file) results.require(file: imagePath, source: page.path) guard let size = results.getImageSize(atPath: imagePath, source: page.path) else { return "" } let width = Int(size.width) let height = Int(size.height) return factory.html.image(file: file, width: width, height: height, altText: altText) } */