diff --git a/NetNewsWire/MainWindow/Detail/ArticleRenderer.swift b/NetNewsWire/MainWindow/Detail/ArticleRenderer.swift index 2d26b5d0c..d2c6e879f 100644 --- a/NetNewsWire/MainWindow/Detail/ArticleRenderer.swift +++ b/NetNewsWire/MainWindow/Detail/ArticleRenderer.swift @@ -11,78 +11,9 @@ import RSCore import Articles import Account -var cachedStyleString = "" -var cachedTemplate = "" - -// NOTE: THIS CODE IS A TOTAL MESS RIGHT NOW WHILE WE’RE EXPERIMENTING WITH DIFFERENT LAYOUTS. DON’T JUDGE, YOU! - class ArticleRenderer { - let article: Article? - let articleStyle: ArticleStyle - let appearance: NSAppearance? - - static var faviconImgTagCache = [Feed: String]() - static var feedIconImgTagCache = [Feed: String]() - - lazy var longDateFormatter: DateFormatter = { - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .long - dateFormatter.timeStyle = .medium - return dateFormatter - }() - - lazy var mediumDateFormatter: DateFormatter = { - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .medium - dateFormatter.timeStyle = .short - return dateFormatter - }() - - lazy var shortDateFormatter: DateFormatter = { - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .short - dateFormatter.timeStyle = .short - return dateFormatter - }() - - lazy var title: String = { - if let articleTitle = self.article?.title { - return articleTitle - } - - return "" - }() - - lazy var baseURL: URL? = { - - var s = self.article?.url - if s == nil { - s = self.article?.feed?.homePageURL - } - if s == nil { - s = self.article?.feed?.url - } - if s == nil { - return nil - } - - var urlComponents = URLComponents(string: s!) - if urlComponents == nil { - return nil - } - - // Can’t use url-with-fragment as base URL. The webview won’t load. See scripting.com/rss.xml for example. - urlComponents!.fragment = nil - - if let url = urlComponents!.url { - if url.scheme == "http" || url.scheme == "https" { - return url - } - } - - return nil - }() + let baseURL: URL? var articleHTML: String { let body = RSMacroProcessor.renderedText(withTemplate: template(), substitutions: substitutions(), macroStart: "[[", macroEnd: "]]") @@ -93,86 +24,70 @@ class ArticleRenderer { let body = "

Multiple selection

" return renderHTML(withBody: body) } - + var noSelectionHTML: String { let body = "

No selection

" return renderHTML(withBody: body) } - + + private let article: Article? + private let articleStyle: ArticleStyle + private let appearance: NSAppearance? + private let title: String + init(article: Article?, style: ArticleStyle, appearance: NSAppearance? = nil) { self.article = article self.articleStyle = style self.appearance = appearance - } - - // MARK: Private - - private func textInsideTag(_ text: String, _ tag: String) -> String { - - return "<\(tag)>\(text)" - } - - private func styleString() -> String { - - if let s = articleStyle.css { - return s + self.title = article?.title ?? "" + if let article = article { + self.baseURL = ArticleRenderer.baseURL(for: article) } - - if cachedStyleString.isEmpty { - - let path = Bundle.main.path(forResource: "styleSheet", ofType: "css")! - let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) - cachedStyleString = "\n\(s)\n" + else { + self.baseURL = nil } + } +} - return cachedStyleString +// MARK: Private + +private extension ArticleRenderer { + + static var faviconImgTagCache = [Feed: String]() + static var feedIconImgTagCache = [Feed: String]() + + 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 textInsideTag(_ text: String, _ tag: String) -> String { +// return "<\(tag)>\(text)" +// } + + func styleString() -> String { + return articleStyle.css ?? ArticleRenderer.defaultStyleSheet } - private func template() -> String { - - if let s = articleStyle.template { - return s - } - - if cachedTemplate.isEmpty { - let path = Bundle.main.path(forResource: "template", ofType: "html")! - let s = try! NSString(contentsOfFile: path, encoding: String.Encoding.utf8.rawValue) - cachedTemplate = s as String - } - - return cachedTemplate + func template() -> String { + return articleStyle.template ?? ArticleRenderer.defaultTemplate } - private func linkWithTextAndClass(_ text: String, _ href: String, _ className: String) -> String { - - return "\(text)" - } - - private func linkWithText(_ text: String, _ href: String) -> String { - - return ArticleRenderer.linkWithText(text, href) - } - - private static func linkWithText(_ text: String, _ href: String) -> String { - - return "\(text)" - } - - private func linkWithLink(_ href: String) -> String { - - return linkWithText(href, href) - } - - private func titleOrTitleLink() -> String { - + func titleOrTitleLink() -> String { if let link = article?.preferredLink { - return linkWithText(title, link) + return title.htmlByAddingLink(link) } return title } - private func substitutions() -> [String: String] { - + func substitutions() -> [String: String] { var d = [String: String]() guard let article = article else { @@ -183,13 +98,12 @@ class ArticleRenderer { let title = titleOrTitleLink() d["title"] = title - let body = article.body == nil ? "" : article.body + let body = article.body ?? "" d["body"] = body d["avatars"] = "" var didAddAvatar = false if let avatarHTML = avatarImgTag() { -// d["avatars"] = avatarHTML d["avatars"] = "\(avatarHTML)"; didAddAvatar = true } @@ -198,27 +112,26 @@ class ArticleRenderer { if let feedTitle = article.feed?.nameForDisplay { feedLink = feedTitle if let feedURL = article.feed?.homePageURL { - feedLink = linkWithTextAndClass(feedTitle, feedURL, "feedLink") + feedLink = feedLink.htmlByAddingLink(feedURL, className: "feedLink") } } d["feedlink"] = feedLink - d["feedlink_withfavicon"] = feedLink -// d["favicon"] = "" if !didAddAvatar, let feed = article.feed { if let favicon = faviconImgTag(forFeed: feed) { d["avatars"] = "\(favicon)"; -// d["favicon"] = favicon } } - let longDate = longDateFormatter.string(from: article.logicalDatePublished) - let mediumDate = mediumDateFormatter.string(from: article.logicalDatePublished) - let shortDate = shortDateFormatter.string(from: article.logicalDatePublished) + let datePublished = article.logicalDatePublished + let longDate = dateString(datePublished, .long, .medium) + let mediumDate = dateString(datePublished, .medium, .short) + let shortDate = dateString(datePublished, .short, .short) + if dateShouldBeLink() || self.title == "", let permalink = article.url { - d["date_long"] = linkWithText(longDate, permalink) - d["date_medium"] = linkWithText(mediumDate, permalink) - d["date_short"] = linkWithText(shortDate, permalink) + d["date_long"] = longDate.htmlByAddingLink(permalink) + d["date_medium"] = mediumDate.htmlByAddingLink(permalink) + d["date_short"] = shortDate.htmlByAddingLink(permalink) } else { d["date_long"] = longDate @@ -227,12 +140,11 @@ class ArticleRenderer { } d["byline"] = byline() - // d["author_avatar"] = authorAvatar() return d } - private func dateShouldBeLink() -> Bool { + func dateShouldBeLink() -> Bool { guard let permalink = article?.url else { return false } @@ -242,21 +154,7 @@ class ArticleRenderer { return permalink != preferredLink // Make date a link if it’s a different link from the title’s link } - struct Avatar { - let imageURL: String - let url: String? - - func html(dimension: Int) -> String { - - let imageTag = "" - if let url = url { - return linkWithText(imageTag, url) - } - return imageTag - } - } - - private func faviconImgTag(forFeed feed: Feed) -> String? { + func faviconImgTag(forFeed feed: Feed) -> String? { if let cachedImgTag = ArticleRenderer.faviconImgTagCache[feed] { return cachedImgTag @@ -264,14 +162,14 @@ class ArticleRenderer { if let favicon = appDelegate.faviconDownloader.favicon(for: feed) { if let s = base64String(forImage: favicon) { - var dimension = min(favicon.size.height, CGFloat(avatarDimension)) // Assuming square images. + var dimension = min(favicon.size.height, CGFloat(ArticleRenderer.avatarDimension)) // Assuming square images. dimension = max(dimension, 16) // Some favicons say they’re < 16. Force them larger. - if dimension >= CGFloat(avatarDimension) * 0.8 { //Close enough to scale up. - dimension = CGFloat(avatarDimension) + if dimension >= CGFloat(ArticleRenderer.avatarDimension) * 0.8 { //Close enough to scale up. + dimension = CGFloat(ArticleRenderer.avatarDimension) } let imgTag: String - if dimension >= CGFloat(avatarDimension) { + if dimension >= CGFloat(ArticleRenderer.avatarDimension) { // Use rounded corners. imgTag = "" } @@ -286,8 +184,7 @@ class ArticleRenderer { return nil } - private func feedIconImgTag(forFeed feed: Feed) -> String? { - + func feedIconImgTag(forFeed feed: Feed) -> String? { if let cachedImgTag = ArticleRenderer.feedIconImgTagCache[feed] { return cachedImgTag } @@ -303,116 +200,57 @@ class ArticleRenderer { return nil } - private func base64String(forImage image: NSImage) -> String? { - - - let d = image.tiffRepresentation - return d?.base64EncodedString() + func base64String(forImage image: NSImage) -> String? { + return image.tiffRepresentation?.base64EncodedString() } - private func singleArticleSpecifiedAuthor() -> Author? { - + func singleArticleSpecifiedAuthor() -> Author? { // The author of this article, if just one. - if let authors = article?.authors, authors.count == 1 { return authors.first! } return nil } - private func singleFeedSpecifiedAuthor() -> Author? { - + func singleFeedSpecifiedAuthor() -> Author? { if let authors = article?.feed?.authors, authors.count == 1 { return authors.first! } return nil } - private func feedAvatar() -> Avatar? { + static let avatarDimension = 48 - guard let feedIconURL = article?.feed?.iconURL else { - return nil + struct Avatar { + let imageURL: String + let url: String? + + func html(dimension: Int) -> String { + let imageTag = "" + if let url = url { + return imageTag.htmlByAddingLink(url) + } + return imageTag } - return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url) } - private func authorAvatar() -> Avatar? { - + func avatarImgTag() -> String? { if let author = singleArticleSpecifiedAuthor(), let imageURL = author.avatarURL { - return Avatar(imageURL: imageURL, url: author.url) - } - if let author = singleFeedSpecifiedAuthor(), let imageURL = author.avatarURL { - return Avatar(imageURL: imageURL, url: author.url) - } - return nil - } - - private func avatarsToShow() -> [Avatar]? { - - var avatars = [Avatar]() - if let avatar = feedAvatar() { - avatars.append(avatar) - } - if let avatar = authorAvatar() { - avatars.append(avatar) - } - return avatars.isEmpty ? nil : avatars - } - - private func avatarToUse() -> Avatar? { - - // Use author if article specifies an author, otherwise use feed icon. - // If no feed icon, use feed-specified author. - - if let author = singleArticleSpecifiedAuthor(), let imageURL = author.avatarURL { - return Avatar(imageURL: imageURL, url: author.url) - } - if let feedIconURL = article?.feed?.iconURL { - return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url) - } - if let author = singleFeedSpecifiedAuthor(), let imageURL = author.avatarURL { - return Avatar(imageURL: imageURL, url: author.url) - } - return nil - } - - private let avatarDimension = 48 - - private func avatarImgTag() -> String? { - - if let author = singleArticleSpecifiedAuthor(), let imageURL = author.avatarURL { - return Avatar(imageURL: imageURL, url: author.url).html(dimension: avatarDimension) + return Avatar(imageURL: imageURL, url: author.url).html(dimension: ArticleRenderer.avatarDimension) } if let feed = article?.feed, let imgTag = feedIconImgTag(forFeed: feed) { return imgTag } if let feedIconURL = article?.feed?.iconURL { - return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url).html(dimension: avatarDimension) + return Avatar(imageURL: feedIconURL, url: article?.feed?.homePageURL ?? article?.feed?.url).html(dimension: ArticleRenderer.avatarDimension) } if let author = singleFeedSpecifiedAuthor(), let imageURL = author.avatarURL { - return Avatar(imageURL: imageURL, url: author.url).html(dimension: avatarDimension) + return Avatar(imageURL: imageURL, url: author.url).html(dimension: ArticleRenderer.avatarDimension) } return nil } -// private func authorAvatar() -> String { -// -// guard let authors = article.authors, authors.count == 1, let author = authors.first else { -// return "" -// } -// guard let avatarURL = author.avatarURL else { -// return "" -// } -// -// var imageTag = "" -// if let authorURL = author.url { -// imageTag = linkWithText(imageTag, authorURL) -// } -// return "
\(imageTag)
" -// } - - private func byline() -> String { - + func byline() -> String { guard let authors = article?.authors ?? article?.feed?.authors, !authors.isEmpty else { return "" } @@ -439,11 +277,10 @@ class ArticleRenderer { byline += emailAddress // probably name plus email address } else if let name = author.name, let url = author.url { - byline += linkWithText(name, url) + byline += name.htmlByAddingLink(url) } else if let name = author.name, let emailAddress = author.emailAddress { byline += "\(name) <\(emailAddress)≶" -// byline += linkWithText(name, "mailto:\(emailAddress)") //TODO } else if let name = author.name { byline += name @@ -452,19 +289,50 @@ class ArticleRenderer { byline += "<\(emailAddress)>" // TODO: mailto link } else if let url = author.url { - byline += linkWithLink(url) + byline += String.htmlWithLink(url) } } return byline - } - private func renderHTML(withBody body: String) -> String { + 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) + } + + static func baseURL(for article: Article) -> URL? { + var s = article.url + if s == nil { + s = article.feed?.homePageURL + } + if s == nil { + s = article.feed?.url + } + + guard let urlString = s else { + return nil + } + var urlComponents = URLComponents(string: urlString) + if urlComponents == nil { + return nil + } + + // Can’t use url-with-fragment as base URL. The webview won’t 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 + } + + func renderHTML(withBody body: String) -> String { var s = "\n\n" - s += textInsideTag(title, "title") - s += textInsideTag(styleString(), "style") + s += title.htmlBySurroundingWithTag("title") + s += styleString().htmlBySurroundingWithTag("style") s += """ @@ -492,15 +360,12 @@ class ArticleRenderer { let appearanceClass = appearance?.isDarkMode ?? false ? "dark" : "light" s += "\n\n\n\n" - s += body - s += "\n\n" - //print(s) + //print(s) return s } - }