NetNewsWire/Shared/Article Rendering/ArticleRenderer.swift

336 lines
9.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// ArticleRenderer.swift
// NetNewsWire
//
// Created by Brent Simmons on 9/8/15.
// Copyright © 2015 Ranchero Software, LLC. All rights reserved.
//
import Foundation
#if os(iOS)
import UIKit
#endif
import RSCore
import Articles
import Account
struct ArticleRenderer {
typealias Rendering = (style: String, html: String, title: String, baseURL: String)
struct Page {
let url: URL
let baseURL: URL
let html: String
init(name: String) {
url = Bundle.main.url(forResource: name, withExtension: "html")!
baseURL = url.deletingLastPathComponent()
html = try! NSString(contentsOfFile: url.path, encoding: String.Encoding.utf8.rawValue) as String
}
}
static var imageIconScheme = "nnwImageIcon"
static var blank = Page(name: "blank")
static var page = Page(name: "page")
private let article: Article?
private let extractedArticle: ExtractedArticle?
private let articleStyle: ArticleStyle
private let title: String
private let body: String
private let baseURL: String?
private init(article: Article?, extractedArticle: ExtractedArticle?, style: ArticleStyle) {
self.article = article
self.extractedArticle = extractedArticle
self.articleStyle = style
self.title = article?.sanitizedTitle() ?? ""
if let content = extractedArticle?.content {
self.body = content
self.baseURL = extractedArticle?.url
} else {
self.body = article?.body ?? ""
self.baseURL = article?.baseURL?.absoluteString
}
}
// MARK: - API
static func articleHTML(article: Article, extractedArticle: ExtractedArticle? = nil, style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: article, extractedArticle: extractedArticle, style: style)
return (renderer.articleCSS, renderer.articleHTML, renderer.title, renderer.baseURL ?? "")
}
static func multipleSelectionHTML(style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style)
return (renderer.articleCSS, renderer.multipleSelectionHTML, renderer.title, renderer.baseURL ?? "")
}
static func loadingHTML(style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style)
return (renderer.articleCSS, renderer.loadingHTML, renderer.title, renderer.baseURL ?? "")
}
static func noSelectionHTML(style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style)
return (renderer.articleCSS, renderer.noSelectionHTML, renderer.title, renderer.baseURL ?? "")
}
static func noContentHTML(style: ArticleStyle) -> Rendering {
let renderer = ArticleRenderer(article: nil, extractedArticle: nil, style: style)
return (renderer.articleCSS, renderer.noContentHTML, renderer.title, renderer.baseURL ?? "")
}
}
// MARK: - Private
private extension ArticleRenderer {
private var articleHTML: String {
return try! MacroProcessor.renderedText(withTemplate: template(), substitutions: articleSubstitutions())
}
private var multipleSelectionHTML: String {
let body = "<h3 class='systemMessage'>Multiple selection</h3>"
return body
}
private var loadingHTML: String {
let body = "<h3 class='systemMessage'>Loading...</h3>"
return body
}
private var noSelectionHTML: String {
let body = "<h3 class='systemMessage'>No selection</h3>"
return body
}
private var noContentHTML: String {
return ""
}
private var articleCSS: String {
return try! MacroProcessor.renderedText(withTemplate: styleString(), substitutions: styleSubstitutions())
}
static var defaultStyleSheet: String = {
let path = Bundle.main.path(forResource: "styleSheet", ofType: "css")!
let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue)
return "\n\(s)\n"
}()
static let defaultTemplate: String = {
let path = Bundle.main.path(forResource: "template", ofType: "html")!
let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue)
return s as String
}()
func styleString() -> String {
return articleStyle.css ?? ArticleRenderer.defaultStyleSheet
}
func template() -> String {
return articleStyle.template ?? ArticleRenderer.defaultTemplate
}
func titleOrTitleLink() -> String {
if let link = article?.preferredLink {
return title.htmlByAddingLink(link)
}
return title
}
func articleSubstitutions() -> [String: String] {
var d = [String: String]()
guard let article = article else {
assertionFailure("Article should have been set before calling this function.")
return d
}
let title = titleOrTitleLink()
d["title"] = title
d["body"] = body
var components = URLComponents()
components.scheme = Self.imageIconScheme
components.path = article.articleID
if let imageIconURLString = components.string {
d["avatars"] = "<td class=\"header rightAlign avatar\"><img id=\"nnwImageIcon\" src=\"\(imageIconURLString)\" height=48 width=48 /></td>"
}
else {
d["avatars"] = ""
}
var feedLink = ""
if let feedTitle = article.webFeed?.nameForDisplay {
feedLink = feedTitle
if let feedURL = article.webFeed?.homePageURL {
feedLink = feedLink.htmlByAddingLink(feedURL, className: "feedLink")
}
}
d["feedlink"] = feedLink
let datePublished = article.logicalDatePublished
let longDate = dateString(datePublished, .long, .medium)
let mediumDate = dateString(datePublished, .medium, .short)
let shortDate = dateString(datePublished, .short, .short)
if let permalink = article.url {
d["date_long"] = longDate.htmlByAddingLink(permalink)
d["date_medium"] = mediumDate.htmlByAddingLink(permalink)
d["date_short"] = shortDate.htmlByAddingLink(permalink)
}
else {
d["date_long"] = longDate
d["date_medium"] = mediumDate
d["date_short"] = shortDate
}
d["byline"] = byline()
return d
}
func byline() -> String {
guard let authors = article?.authors ?? article?.webFeed?.authors, !authors.isEmpty else {
return ""
}
// If the author's name is the same as the feed, then we don't want to display it.
// This code assumes that multiple authors would never match the feed name so that
// if there feed owner has an article co-author all authors are given the byline.
if authors.count == 1, let author = authors.first {
if author.name == article?.webFeed?.nameForDisplay {
return ""
}
}
var byline = ""
var isFirstAuthor = true
for author in authors {
if !isFirstAuthor {
byline += ", "
}
isFirstAuthor = false
if let emailAddress = author.emailAddress, emailAddress.contains(" ") {
byline += emailAddress // probably name plus email address
}
else if let name = author.name, let url = author.url {
byline += name.htmlByAddingLink(url)
}
else if let name = author.name, let emailAddress = author.emailAddress {
byline += "\(name) &lt;\(emailAddress)&gt;"
}
else if let name = author.name {
byline += name
}
else if let emailAddress = author.emailAddress {
byline += "&lt;\(emailAddress)&gt;" // TODO: mailto link
}
else if let url = author.url {
byline += String.htmlWithLink(url)
}
}
return byline
}
func dateString(_ date: Date, _ dateStyle: DateFormatter.Style, _ timeStyle: DateFormatter.Style) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = dateStyle
dateFormatter.timeStyle = timeStyle
return dateFormatter.string(from: date)
}
#if os(iOS)
func styleSubstitutions() -> [String: String] {
var d = [String: String]()
let bodyFont = UIFont.preferredFont(forTextStyle: .body)
d["font-size"] = String(describing: bodyFont.pointSize)
if let components = UIColor(named: "AccentColor")?.cgColor.components {
d["accent-r"] = String(Int(round(components[0] * 0xFF)))
d["accent-g"] = String(Int(round(components[1] * 0xFF)))
d["accent-b"] = String(Int(round(components[2] * 0xFF)))
}
return d
}
#else
func styleSubstitutions() -> [String: String] {
var d = [String: String]()
if #available(macOS 11.0, *) {
let bodyFont = NSFont.preferredFont(forTextStyle: .body)
d["font-size"] = String(describing: Int(round(bodyFont.pointSize * 1.33)))
}
guard let linkColor = NSColor.controlAccentColor.usingColorSpace(.deviceRGB) else {
return d
}
let red: Int
let green: Int
let blue: Int
if NSApplication.shared.effectiveAppearance.isDarkMode {
let brighten = CGFloat(0.50)
let baseRed = linkColor.redComponent * 0xFF
red = Int(round(((255 - baseRed) * brighten)) + round(baseRed))
let baseGreen = linkColor.greenComponent * 0xFF
green = Int(round(((255 - baseGreen) * brighten)) + round(baseGreen))
let baseBlue = linkColor.blueComponent * 0xFF
blue = Int(round(((255 - baseBlue) * brighten)) + round(baseBlue))
} else {
let darken = CGFloat(0.75)
red = Int(round(linkColor.redComponent * 0xFF * darken))
green = Int(round(linkColor.greenComponent * 0xFF * darken))
blue = Int(round(linkColor.blueComponent * 0xFF * darken))
}
d["accent-r"] = String(red)
d["accent-g"] = String(green)
d["accent-b"] = String(blue)
return d
}
#endif
}
// MARK: - Article extension
private extension Article {
var baseURL: URL? {
var s = url
if s == nil {
s = webFeed?.homePageURL
}
if s == nil {
s = webFeed?.url
}
guard let urlString = s else {
return nil
}
var urlComponents = URLComponents(string: urlString)
if urlComponents == nil {
return nil
}
// Cant use url-with-fragment as base URL. The webview wont load. See scripting.com/rss.xml for example.
urlComponents!.fragment = nil
guard let url = urlComponents!.url, url.scheme == "http" || url.scheme == "https" else {
return nil
}
return url
}
}