Replace SwiftUI renderer with HTML/WKWebView

This commit is contained in:
Shadowfacts 2020-12-16 23:01:44 -05:00
parent 9d1d8828a0
commit 2d60f733c3
10 changed files with 653 additions and 3 deletions

View File

@ -0,0 +1,97 @@
//
// BrowserNavigationController.swift
// Gemini-iOS
//
// Created by Shadowfacts on 12/16/20.
//
import UIKit
import BrowserCore
import Combine
class BrowserNavigationController: UINavigationController {
let navigator: NavigationManager
var poppedViewControllers = [UIViewController]()
var skipResetPoppedOnNextPush = false
private var interactivePushTransition: InteractivePushTransition!
private var cancellables = [AnyCancellable]()
init(navigator: NavigationManager) {
self.navigator = navigator
super.init(nibName: nil, bundle: nil)
navigator.$currentURL
.sink { [weak self] (newURL) in
self?.pushViewController(BrowserWebViewController(navigator: navigator, url: newURL), animated: true)
}
.store(in: &cancellables)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
interactivePushTransition = InteractivePushTransition(navigationController: self)
}
override func popViewController(animated: Bool) -> UIViewController? {
if let popped = super.popViewController(animated: animated) {
poppedViewControllers.insert(popped, at: 0)
return popped
} else {
return nil
}
}
override func popToRootViewController(animated: Bool) -> [UIViewController]? {
if let popped = super.popToRootViewController(animated: animated) {
poppedViewControllers = popped
return popped
} else {
return nil
}
}
override func popToViewController(_ viewController: UIViewController, animated: Bool) -> [UIViewController]? {
if let popped = super.popToViewController(viewController, animated: animated) {
poppedViewControllers.insert(contentsOf: popped, at: 0)
return popped
} else {
return nil
}
}
override func pushViewController(_ viewController: UIViewController, animated: Bool) {
if skipResetPoppedOnNextPush {
skipResetPoppedOnNextPush = false
} else {
self.poppedViewControllers = []
}
super.pushViewController(viewController, animated: animated)
}
func onWillShow() {
self.transitionCoordinator?.notifyWhenInteractionChanges({ (context) in
if context.isCancelled {
if self.interactivePushTransition.interactive {
// when an interactive push gesture is cancelled, make sure to adding the VC that was being pushed back onto the popped stack so it doesn't disappear
self.poppedViewControllers.insert(self.interactivePushTransition.pushingViewController!, at: 0)
} else {
// when an interactive pop gesture is cancelled (i.e. the user lifts their finger before it triggers),
// the popViewController(animated:) method has already been called so the VC has already been added to the popped stack
// so we make sure to remove it, otherwise there could be duplicate VCs on the navigation stasck
self.poppedViewControllers.remove(at: 0)
}
}
})
}
}

View File

@ -0,0 +1,188 @@
//
// BrowserWebViewController.swift
// Gemini-iOS
//
// Created by Shadowfacts on 12/16/20.
//
import UIKit
import BrowserCore
import WebKit
import GeminiProtocol
import GeminiFormat
import GeminiRenderer
class BrowserWebViewController: UIViewController {
let navigator: NavigationManager
let url: URL
private var task: GeminiDataTask?
private let renderer = GeminiHTMLRenderer()
private var loaded = false
private var errorStack: UIStackView!
private var errorMessageLabel: UILabel!
private var activityIndicator: UIActivityIndicatorView!
private var webView: WKWebView!
init(navigator: NavigationManager, url: URL) {
self.navigator = navigator
self.url = url
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
webView = WKWebView()
webView.navigationDelegate = self
webView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(webView)
NSLayoutConstraint.activate([
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
webView.topAnchor.constraint(equalTo: view.topAnchor),
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
let errorTitle = UILabel()
errorTitle.text = "An error occurred"
errorTitle.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .headline).withDesign(.serif)!, size: 0)
errorTitle.numberOfLines = 0
errorMessageLabel = UILabel()
errorMessageLabel.font = UIFont(descriptor: UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body).withDesign(.serif)!, size: 0)
errorMessageLabel.numberOfLines = 0
errorStack = UIStackView(arrangedSubviews: [
errorTitle,
errorMessageLabel,
])
errorStack.translatesAutoresizingMaskIntoConstraints = false
errorStack.axis = .vertical
errorStack.alignment = .center
errorStack.isHidden = true
view.addSubview(errorStack)
NSLayoutConstraint.activate([
errorStack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
errorStack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
errorStack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
])
activityIndicator = UIActivityIndicatorView(style: .large)
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
activityIndicator.isHidden = true
view.addSubview(activityIndicator)
NSLayoutConstraint.activate([
activityIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor),
activityIndicator.centerYAnchor.constraint(equalTo: view.centerYAnchor),
])
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadDocument()
}
private func loadDocument() {
guard !loaded else { return }
webView.isHidden = true
errorStack.isHidden = true
activityIndicator.isHidden = false
activityIndicator.startAnimating()
let url = self.url
task = try! GeminiDataTask(url: url) { (response) in
self.task = nil
self.loaded = true
switch response {
case let .failure(error):
DispatchQueue.main.async {
self.showError(message: error.localizedDescription)
}
case let .success(response):
if response.status.isRedirect {
if let redirect = URL(string: response.meta) {
self.navigator.changeURL(redirect)
} else {
DispatchQueue.main.async {
self.showError(message: "Invalid redirect URL: '\(response.meta)'")
}
}
} else if response.status.isSuccess,
let text = response.bodyText {
self.renderDocument(GeminiParser.parse(text: text, baseURL: url))
} else {
DispatchQueue.main.async {
self.showError(message: "Unknown error: \(response.header)")
}
}
}
}
task!.resume()
}
private func showError(message: String) {
webView.isHidden = true
errorStack.isHidden = false
activityIndicator.isHidden = true
activityIndicator.stopAnimating()
errorMessageLabel.text = message
}
private func renderDocument(_ doc: Document) {
let html = BrowserWebViewController.preamble + renderer.renderDocumentToHTML(doc) + BrowserWebViewController.postamble
DispatchQueue.main.async {
self.webView.isHidden = false
self.errorStack.isHidden = true
self.activityIndicator.isHidden = true
self.activityIndicator.stopAnimating()
if let title = doc.title {
self.navigationItem.title = title
}
self.webView.loadHTMLString(html, baseURL: Bundle.main.bundleURL)
}
}
private static let preamble = """
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<link rel="stylesheet" href="\(Bundle.main.url(forResource: "browser", withExtension: "css")!.absoluteString)">
</head>
<body>
"""
private static let postamble = """
</body>
</html>
"""
}
extension BrowserWebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
let url = navigationAction.request.url!
if url.scheme == "file" {
decisionHandler(.allow)
} else {
decisionHandler(.cancel)
navigator.changeURL(url)
loadDocument()
}
}
}

View File

@ -0,0 +1,142 @@
//
// InteractivePushTransition.swift
// Gemini-iOS
//
// Created by Shadowfacts on 12/16/20.
//
import UIKit
/// Allows interactively moving forward through the navigation stack after popping
/// Based on https://github.com/NSExceptional/TBInteractivePushTransition
class InteractivePushTransition: UIPercentDrivenInteractiveTransition {
fileprivate let minimumPushVelocityThreshold: CGFloat = 700
fileprivate let minimumPushDistanceThreshold: CGFloat = 0.5
fileprivate let pushAnimationDuration: TimeInterval = 0.35
private(set) weak var navigationController: BrowserNavigationController!
private(set) weak var interactivePushGestureRecognizer: UIScreenEdgePanGestureRecognizer!
private(set) var interactive = false
private(set) weak var pushingViewController: UIViewController?
init(navigationController: BrowserNavigationController) {
super.init()
self.navigationController = navigationController
let interactivePushGestureRecognizer = UIScreenEdgePanGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:)))
self.interactivePushGestureRecognizer = interactivePushGestureRecognizer
navigationController.delegate = self
interactivePushGestureRecognizer.edges = .right
interactivePushGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
navigationController.view.addGestureRecognizer(interactivePushGestureRecognizer)
let trackpadGestureRecognizer = TrackpadScrollGestureRecognizer(target: self, action: #selector(handleSwipeForward(_:)))
trackpadGestureRecognizer.require(toFail: navigationController.interactivePopGestureRecognizer!)
navigationController.view.addGestureRecognizer(trackpadGestureRecognizer)
}
@objc func handleSwipeForward(_ recognizer: UIPanGestureRecognizer) {
interactive = true
let velocity = recognizer.velocity(in: recognizer.view)
let translation = recognizer.translation(in: recognizer.view)
let dx = -translation.x / navigationController.view.bounds.width
let vx = -velocity.x
switch recognizer.state {
case .began:
if let viewController = navigationController.poppedViewControllers.first {
pushingViewController = viewController
navigationController.poppedViewControllers.removeFirst()
navigationController.skipResetPoppedOnNextPush = true
navigationController.pushViewController(viewController, animated: true)
} else {
interactive = false
}
case .changed:
update(dx)
case .ended:
if (dx > minimumPushDistanceThreshold || vx > minimumPushVelocityThreshold) {
finish()
} else {
cancel()
}
interactive = false
default:
cancel()
interactive = false
}
}
}
extension InteractivePushTransition: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
self.navigationController.onWillShow()
}
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
if operation == .push, interactive {
return self
}
return nil
}
func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
if interactive {
return self
}
return nil
}
}
extension InteractivePushTransition: UIViewControllerAnimatedTransitioning {
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
return pushAnimationDuration
}
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
let toView = transitionContext.view(forKey: .to)!
let fromView = transitionContext.view(forKey: .from)!
let dimmingView = UIView()
dimmingView.backgroundColor = .black
dimmingView.alpha = 0
dimmingView.frame = fromView.frame
transitionContext.containerView.addSubview(dimmingView)
transitionContext.containerView.addSubview(toView)
// modify frame of presented view to go from x = <screen width> to x = 0
var frame = fromView.frame
frame.origin.x = frame.width
toView.frame = frame
frame.origin.x = 0
// we want it linear while interactive, but we want the "ease out" animation
// if the user flicks the screen ahrd enough to finish the transition without interaction
let options = interactive ? UIView.AnimationOptions.curveLinear : .curveEaseOut
let duration = transitionDuration(using: transitionContext)
UIView.animate(withDuration: duration, delay: 0, options: options, animations: {
// these magic numbers scientifically determined by repeatedly adjusting and comparing to the system animation
let translationDistance = -frame.size.width * 0.3
fromView.transform = CGAffineTransform(translationX: translationDistance, y: 0)
toView.frame = frame
dimmingView.alpha = 0.075
}) { (finished) in
fromView.transform = .identity
dimmingView.removeFromSuperview()
transitionContext.completeTransition(!transitionContext.transitionWasCancelled)
}
}
}

View File

@ -0,0 +1,40 @@
body {
font-family: ui-serif;
overflow-wrap: break-word;
}
pre {
font-family: ui-monospace;
overflow-wrap: normal;
overflow-x: auto;
tab-size: 4;
}
h1, h2, h3 {
line-height: 1;
font-weight: normal;
}
p.link {
display: block;
margin: 8px 0;
}
a {
color: rgb(0, 122, 255);
}
ul {
padding-left: 20px;
}
@media (prefers-color-scheme: dark) {
body {
background-color: black;
color: white;
}
a {
color: rgb(10, 132, 255);
}
}

View File

@ -40,13 +40,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
navigationManager.delegate = self
// Create the SwiftUI view that provides the window contents.
let contentView = ContentView(navigator: navigationManager)
// let contentView = ContentView(navigator: navigationManager)
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
window.overrideUserInterfaceStyle = Preferences.shared.theme
window.rootViewController = UIHostingController(rootView: contentView)
window.rootViewController = BrowserNavigationController(navigator: navigationManager)
// window.rootViewController = UIHostingController(rootView: contentView)
// window.rootViewController = BrowserViewController(navigator: navigationManager)
self.window = window
window.makeKeyAndVisible()

View File

@ -0,0 +1,22 @@
//
// TrackpadScrollGestureRecognizer.swift
// Gemini-iOS
//
// Created by Shadowfacts on 12/16/20.
//
import UIKit
class TrackpadScrollGestureRecognizer: UIPanGestureRecognizer {
override init(target: Any?, action: Selector?) {
super.init(target: target, action: action)
self.allowedScrollTypesMask = .all
}
override func shouldReceive(_ event: UIEvent) -> Bool {
return event.type == .scroll
}
}

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 50;
objectVersion = 52;
objects = {
/* Begin PBXBuildFile section */
@ -40,6 +40,13 @@
D664673624BD07F700B0B741 /* RenderingBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673524BD07F700B0B741 /* RenderingBlock.swift */; };
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673724BD086F00B0B741 /* RenderingBlockView.swift */; };
D664673A24BD0B8E00B0B741 /* Fonts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D664673924BD0B8E00B0B741 /* Fonts.swift */; };
D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */; };
D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */ = {isa = PBXBuildFile; productRef = D688F58F258AC814003A0A73 /* HTMLEntities */; };
D688F599258ACAAE003A0A73 /* BrowserWebViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */; };
D688F5FF258ACE6B003A0A73 /* browser.css in Resources */ = {isa = PBXBuildFile; fileRef = D688F5FE258ACE6B003A0A73 /* browser.css */; };
D688F621258B0811003A0A73 /* BrowserNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F620258B0811003A0A73 /* BrowserNavigationController.swift */; };
D688F62A258B0833003A0A73 /* InteractivePushTransition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F629258B0833003A0A73 /* InteractivePushTransition.swift */; };
D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */; };
D691A64E25217C6F00348C4B /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A64D25217C6F00348C4B /* Preferences.swift */; };
D691A66725217FD800348C4B /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A66625217FD800348C4B /* PreferencesView.swift */; };
D691A68725223A4700348C4B /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D691A68625223A4600348C4B /* NavigationBar.swift */; };
@ -298,6 +305,12 @@
D664673524BD07F700B0B741 /* RenderingBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlock.swift; sourceTree = "<group>"; };
D664673724BD086F00B0B741 /* RenderingBlockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RenderingBlockView.swift; sourceTree = "<group>"; };
D664673924BD0B8E00B0B741 /* Fonts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Fonts.swift; sourceTree = "<group>"; };
D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeminiHTMLRenderer.swift; sourceTree = "<group>"; };
D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebViewController.swift; sourceTree = "<group>"; };
D688F5FE258ACE6B003A0A73 /* browser.css */ = {isa = PBXFileReference; lastKnownFileType = text.css; path = browser.css; sourceTree = "<group>"; };
D688F620258B0811003A0A73 /* BrowserNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserNavigationController.swift; sourceTree = "<group>"; };
D688F629258B0833003A0A73 /* InteractivePushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractivePushTransition.swift; sourceTree = "<group>"; };
D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackpadScrollGestureRecognizer.swift; sourceTree = "<group>"; };
D691A64D25217C6F00348C4B /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
D691A66625217FD800348C4B /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = "<group>"; };
D691A6762522382E00348C4B /* BrowserViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserViewController.swift; sourceTree = "<group>"; };
@ -373,6 +386,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
D688F590258AC814003A0A73 /* HTMLEntities in Frameworks */,
D62664F024BC0D7700DF9B88 /* GeminiFormat.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -530,6 +544,7 @@
D62664EB24BC0B4D00DF9B88 /* DocumentView.swift */,
D664673724BD086F00B0B741 /* RenderingBlockView.swift */,
D6DA5782252396030048B65A /* View+Extensions.swift */,
D688F585258AC738003A0A73 /* GeminiHTMLRenderer.swift */,
);
path = GeminiRenderer;
sourceTree = "<group>";
@ -550,11 +565,23 @@
name = Frameworks;
sourceTree = "<group>";
};
D688F618258AD231003A0A73 /* Resources */ = {
isa = PBXGroup;
children = (
D688F5FE258ACE6B003A0A73 /* browser.css */,
);
path = Resources;
sourceTree = "<group>";
};
D6E152A324BFFDF500FDF9D3 /* Gemini-iOS */ = {
isa = PBXGroup;
children = (
D6E152A424BFFDF500FDF9D3 /* AppDelegate.swift */,
D6E152A624BFFDF500FDF9D3 /* SceneDelegate.swift */,
D688F620258B0811003A0A73 /* BrowserNavigationController.swift */,
D688F629258B0833003A0A73 /* InteractivePushTransition.swift */,
D688F632258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift */,
D688F598258ACAAE003A0A73 /* BrowserWebViewController.swift */,
D691A6762522382E00348C4B /* BrowserViewController.swift */,
D6E152A824BFFDF500FDF9D3 /* ContentView.swift */,
D691A68625223A4600348C4B /* NavigationBar.swift */,
@ -562,6 +589,7 @@
D691A64D25217C6F00348C4B /* Preferences.swift */,
D691A66625217FD800348C4B /* PreferencesView.swift */,
D62BCEE1252553620031D894 /* ActivityView.swift */,
D688F618258AD231003A0A73 /* Resources */,
D6E152AA24BFFDF600FDF9D3 /* Assets.xcassets */,
D6E152AF24BFFDF600FDF9D3 /* LaunchScreen.storyboard */,
D6E152B224BFFDF600FDF9D3 /* Info.plist */,
@ -748,6 +776,9 @@
D68544302522E10F004C4AE0 /* PBXTargetDependency */,
);
name = GeminiRenderer;
packageProductDependencies = (
D688F58F258AC814003A0A73 /* HTMLEntities */,
);
productName = GeminiRenderer;
productReference = D62664CE24BC081B00DF9B88 /* GeminiRenderer.framework */;
productType = "com.apple.product-type.framework";
@ -890,6 +921,9 @@
Base,
);
mainGroup = D626645224BBF1C200DF9B88;
packageReferences = (
D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */,
);
productRefGroup = D626645C24BBF1C200DF9B88 /* Products */;
projectDirPath = "";
projectRoot = "";
@ -968,6 +1002,7 @@
files = (
D6E152B124BFFDF600FDF9D3 /* LaunchScreen.storyboard in Resources */,
D6E152AE24BFFDF600FDF9D3 /* Preview Assets.xcassets in Resources */,
D688F5FF258ACE6B003A0A73 /* browser.css in Resources */,
D6E152AB24BFFDF600FDF9D3 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1048,6 +1083,7 @@
D62664EE24BC0BCE00DF9B88 /* MaybeLazyVStack.swift in Sources */,
D62664EC24BC0B4D00DF9B88 /* DocumentView.swift in Sources */,
D6DA5783252396030048B65A /* View+Extensions.swift in Sources */,
D688F586258AC738003A0A73 /* GeminiHTMLRenderer.swift in Sources */,
D664673824BD086F00B0B741 /* RenderingBlockView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -1064,7 +1100,11 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
D688F621258B0811003A0A73 /* BrowserNavigationController.swift in Sources */,
D691A66725217FD800348C4B /* PreferencesView.swift in Sources */,
D688F62A258B0833003A0A73 /* InteractivePushTransition.swift in Sources */,
D688F599258ACAAE003A0A73 /* BrowserWebViewController.swift in Sources */,
D688F633258B09BB003A0A73 /* TrackpadScrollGestureRecognizer.swift in Sources */,
D6E152A524BFFDF500FDF9D3 /* AppDelegate.swift in Sources */,
D691A6A0252242FC00348C4B /* ToolBar.swift in Sources */,
D6E152A724BFFDF500FDF9D3 /* SceneDelegate.swift in Sources */,
@ -1949,6 +1989,25 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Kitura/swift-html-entities";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 3.0.200;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
D688F58F258AC814003A0A73 /* HTMLEntities */ = {
isa = XCSwiftPackageProductDependency;
package = D688F58E258AC814003A0A73 /* XCRemoteSwiftPackageReference "swift-html-entities" */;
productName = HTMLEntities;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = D626645324BBF1C200DF9B88 /* Project object */;
}

View File

@ -0,0 +1,16 @@
{
"object": {
"pins": [
{
"package": "HTMLEntities",
"repositoryURL": "https://github.com/Kitura/swift-html-entities",
"state": {
"branch": null,
"revision": "2b14531d0c36dbb7c1c45a4d38db9c2e7898a307",
"version": "3.0.200"
}
}
]
},
"version": 1
}

View File

@ -21,6 +21,13 @@ public struct Document {
// todo: should this be \r\n
return lines.map { $0.geminiText() }.joined(separator: "\n")
}
public var title: String? {
for case let .heading(text, level: _) in lines {
return text
}
return nil
}
}
public extension Document {

View File

@ -0,0 +1,78 @@
//
// GeminiHTMLRenderer.swift
// GeminiRenderer
//
// Created by Shadowfacts on 12/16/20.
//
import Foundation
import GeminiFormat
import HTMLEntities
public protocol GeminiHTMLRendererDelegate: class {
}
public class GeminiHTMLRenderer {
public weak var delegate: GeminiHTMLRendererDelegate?
public init() {
}
public func renderDocumentToHTML(_ doc: Document) -> String {
var str = ""
var inPreformatting = false
var inList = false
for line in doc.lines {
if inList && !line.isListItem {
str += "</ul>"
}
switch line {
case let .text(text):
str += "<p>\(text.htmlEscape())</p>"
case let .link(url, text: maybeText):
let text = maybeText ?? url.absoluteString
str += "<p class=\"link\"><a href=\"\(url.absoluteString)\">\(text.htmlEscape())</a></p>"
case .preformattedToggle(alt: _):
inPreformatting = !inPreformatting
if inPreformatting {
str += "<pre>"
} else {
str += "</pre>"
}
case let .preformattedText(text):
str += text.htmlEscape()
str += "\n"
case let .heading(text, level: level):
let tag = "h\(level.rawValue)"
str += "<\(tag)>\(text.htmlEscape())</\(tag)>"
case let .unorderedListItem(text):
if !inList {
inList = true
str += "<ul>"
}
str += "<li>\(text.htmlEscape())</li>"
case let .quote(text):
str += "<blockquote>\(text.htmlEscape())</blockquote>"
}
}
return str
}
}
fileprivate extension Document.Line {
var isListItem: Bool {
switch self {
case .unorderedListItem(_):
return true
default:
return false
}
}
}