| @@ -387,14 +387,20 @@ nonisolated func processInline(_ text: String, imageURLResolver: ((String) -> St |
| 387 | let alt = nsText.substring(with: match.range(at: 1)) |
387 | let alt = nsText.substring(with: match.range(at: 1)) |
| 388 | let source = nsText.substring(with: match.range(at: 2)) |
388 | let source = nsText.substring(with: match.range(at: 2)) |
| 389 | let resolvedSource = imageURLResolver?(source) ?? source |
389 | let resolvedSource = imageURLResolver?(source) ?? source |
| 390 | return #"<img src="\#(resolvedSource)" alt="\#(escapeHTMLAttribute(alt))">"# |
390 | guard let sanitizedSource = sanitizedReadmeImageURLString(resolvedSource) else { |
| |
391 | return escapeHTML(alt) |
| |
392 | } |
| |
393 | return #"<img src="\#(sanitizedSource)" alt="\#(escapeHTMLAttribute(alt))">"# |
| 391 | } |
394 | } |
| 392 | // Links: [text](url) |
395 | // Links: [text](url) |
| 393 | result = result.replacingOccurrences( |
396 | result = replaceMatches(in: result, pattern: #"\[([^\]]+)\]\(([^)]+)\)"#) { match, nsText in |
| 394 | of: #"\[([^\]]+)\]\(([^)]+)\)"#, |
397 | let label = nsText.substring(with: match.range(at: 1)) |
| 395 | with: #"<a href="$2">$1</a>"#, |
398 | let rawURL = nsText.substring(with: match.range(at: 2)) |
| 396 | options: .regularExpression |
399 | guard let sanitizedURL = sanitizedReadmeLinkURLString(rawURL) else { |
| 397 | ) |
400 | return label |
| |
401 | } |
| |
402 | return #"<a href="\#(sanitizedURL)">\#(label)</a>"# |
| |
403 | } |
| 398 | // Bold: **text** |
404 | // Bold: **text** |
| 399 | result = result.replacingOccurrences( |
405 | result = result.replacingOccurrences( |
| 400 | of: #"\*\*(.+?)\*\*"#, |
406 | of: #"\*\*(.+?)\*\*"#, |
| @@ -676,7 +682,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St |
| 676 | ) { |
682 | ) { |
| 677 | return imageHTML |
683 | return imageHTML |
| 678 | } |
684 | } |
| 679 | return #"<a href="\#(url)">\#(label)</a>"# |
685 | guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { |
| |
686 | return label |
| |
687 | } |
| |
688 | return #"<a href="\#(sanitizedURL)">\#(label)</a>"# |
| 680 | } |
689 | } |
| 681 | result = protectMatches( |
690 | result = protectMatches( |
| 682 | in: result, |
691 | in: result, |
| @@ -691,7 +700,10 @@ nonisolated private func processOrgInline(_ text: String, imageURLResolver: ((St |
| 691 | ) { |
700 | ) { |
| 692 | return imageHTML |
701 | return imageHTML |
| 693 | } |
702 | } |
| 694 | return #"<a href="\#(url)">\#(url)</a>"# |
703 | guard let sanitizedURL = sanitizedReadmeLinkURLString(url) else { |
| |
704 | return url |
| |
705 | } |
| |
706 | return #"<a href="\#(sanitizedURL)">\#(url)</a>"# |
| 695 | } |
707 | } |
| 696 | result = protectMatches( |
708 | result = protectMatches( |
| 697 | in: result, |
709 | in: result, |
| @@ -742,6 +754,57 @@ nonisolated private func escapeHTMLAttribute(_ text: String) -> String { |
| 742 | escapeHTML(text).replacingOccurrences(of: "'", with: "'") |
754 | escapeHTML(text).replacingOccurrences(of: "'", with: "'") |
| 743 | } |
755 | } |
| 744 | |
756 | |
| |
757 | nonisolated func sanitizedReadmeLinkURLString(_ rawURL: String) -> String? { |
| |
758 | sanitizeReadmeURLString( |
| |
759 | rawURL, |
| |
760 | allowedSchemes: ["http", "https", "mailto"], |
| |
761 | allowsFragmentOnly: true |
| |
762 | ) |
| |
763 | } |
| |
764 | |
| |
765 | nonisolated func sanitizedReadmeImageURLString(_ rawURL: String) -> String? { |
| |
766 | sanitizeReadmeURLString( |
| |
767 | rawURL, |
| |
768 | allowedSchemes: ["http", "https"], |
| |
769 | allowsFragmentOnly: false |
| |
770 | ) |
| |
771 | } |
| |
772 | |
| |
773 | nonisolated func isAllowedReadmeNavigationURL(_ url: URL) -> Bool { |
| |
774 | guard let scheme = url.scheme?.lowercased() else { |
| |
775 | return false |
| |
776 | } |
| |
777 | if scheme == "about" || scheme == "data" { |
| |
778 | return true |
| |
779 | } |
| |
780 | guard let sanitizedURL = sanitizedReadmeLinkURLString(url.absoluteString) else { |
| |
781 | return false |
| |
782 | } |
| |
783 | return sanitizedURL == escapeHTMLAttribute(url.absoluteString) |
| |
784 | } |
| |
785 | |
| |
786 | nonisolated private func sanitizeReadmeURLString( |
| |
787 | _ rawURL: String, |
| |
788 | allowedSchemes: Set<String>, |
| |
789 | allowsFragmentOnly: Bool |
| |
790 | ) -> String? { |
| |
791 | let trimmedURL = rawURL.trimmingCharacters(in: .whitespacesAndNewlines) |
| |
792 | guard !trimmedURL.isEmpty else { return nil } |
| |
793 | |
| |
794 | if allowsFragmentOnly, trimmedURL.hasPrefix("#"), trimmedURL.count > 1 { |
| |
795 | return escapeHTMLAttribute(trimmedURL) |
| |
796 | } |
| |
797 | |
| |
798 | guard let components = URLComponents(string: trimmedURL), |
| |
799 | let scheme = components.scheme?.lowercased(), |
| |
800 | allowedSchemes.contains(scheme), |
| |
801 | let sanitizedURL = components.url?.absoluteString else { |
| |
802 | return nil |
| |
803 | } |
| |
804 | |
| |
805 | return escapeHTMLAttribute(sanitizedURL) |
| |
806 | } |
| |
807 | |
| 745 | nonisolated private func isOrgTableLine(_ line: String) -> Bool { |
808 | nonisolated private func isOrgTableLine(_ line: String) -> Bool { |
| 746 | line.hasPrefix("|") && line.hasSuffix("|") |
809 | line.hasPrefix("|") && line.hasSuffix("|") |
| 747 | } |
810 | } |
| @@ -899,6 +962,7 @@ struct HTMLWebView: View { |
| 899 | let html: String |
962 | let html: String |
| 900 | let colorScheme: ColorScheme |
963 | let colorScheme: ColorScheme |
| 901 | var style: HTMLWebViewStyle = .readme |
964 | var style: HTMLWebViewStyle = .readme |
| |
965 | @Environment(\.openURL) private var openURL |
| 902 | @State private var contentHeight: CGFloat = 1 |
966 | @State private var contentHeight: CGFloat = 1 |
| 903 | @State private var loadError: String? |
967 | @State private var loadError: String? |
| 904 | @State private var reloadToken = 0 |
968 | @State private var reloadToken = 0 |
| @@ -921,6 +985,7 @@ struct HTMLWebView: View { |
| 921 | html: html, |
985 | html: html, |
| 922 | colorScheme: colorScheme, |
986 | colorScheme: colorScheme, |
| 923 | style: style, |
987 | style: style, |
| |
988 | openURL: openURL, |
| 924 | dynamicHeight: $contentHeight, |
989 | dynamicHeight: $contentHeight, |
| 925 | loadError: $loadError, |
990 | loadError: $loadError, |
| 926 | reloadToken: reloadToken |
991 | reloadToken: reloadToken |
| @@ -956,6 +1021,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { |
| 956 | let html: String |
1021 | let html: String |
| 957 | let colorScheme: ColorScheme |
1022 | let colorScheme: ColorScheme |
| 958 | let style: HTMLWebViewStyle |
1023 | let style: HTMLWebViewStyle |
| |
1024 | let openURL: OpenURLAction |
| 959 | @Binding var dynamicHeight: CGFloat |
1025 | @Binding var dynamicHeight: CGFloat |
| 960 | @Binding var loadError: String? |
1026 | @Binding var loadError: String? |
| 961 | let reloadToken: Int |
1027 | let reloadToken: Int |
| @@ -966,12 +1032,13 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable { |
| 966 | |
1032 | |
| 967 | func makeUIView(context: Context) -> WKWebView { |
1033 | func makeUIView(context: Context) -> WKWebView { |
| 968 | let config = WKWebViewConfiguration() |
1034 | let config = WKWebViewConfiguration() |
| 969 | config.defaultWebpagePreferences.allowsContentJavaScript = true |
1035 | config.defaultWebpagePreferences.allowsContentJavaScript = false |
| 970 | config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore |
1036 | config.websiteDataStore = HTMLWebViewCoordinator.websiteDataStore |
| 971 | let webView = WKWebView(frame: .zero, configuration: config) |
1037 | let webView = WKWebView(frame: .zero, configuration: config) |
| 972 | webView.isOpaque = false |
1038 | webView.isOpaque = false |
| 973 | webView.backgroundColor = .clear |
1039 | webView.backgroundColor = .clear |
| 974 | webView.clipsToBounds = false |
1040 | webView.clipsToBounds = false |
| |
1041 | webView.allowsLinkPreview = false |
| 975 | webView.scrollView.isScrollEnabled = false |
1042 | webView.scrollView.isScrollEnabled = false |
| 976 | webView.scrollView.contentInsetAdjustmentBehavior = .never |
1043 | webView.scrollView.contentInsetAdjustmentBehavior = .never |
| 977 | webView.scrollView.clipsToBounds = false |
1044 | webView.scrollView.clipsToBounds = false |
| @@ -1088,6 +1155,31 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc |
| 1088 | handleLoadFailure(error) |
1155 | handleLoadFailure(error) |
| 1089 | } |
1156 | } |
| 1090 | |
1157 | |
| |
1158 | func webView( |
| |
1159 | _ webView: WKWebView, |
| |
1160 | decidePolicyFor navigationAction: WKNavigationAction, |
| |
1161 | decisionHandler: @escaping @MainActor (WKNavigationActionPolicy) -> Void |
| |
1162 | ) { |
| |
1163 | guard let requestURL = navigationAction.request.url else { |
| |
1164 | decisionHandler(.allow) |
| |
1165 | return |
| |
1166 | } |
| |
1167 | |
| |
1168 | if navigationAction.navigationType == .linkActivated { |
| |
1169 | if isAllowedReadmeNavigationURL(requestURL) { |
| |
1170 | parent.openURL(requestURL) |
| |
1171 | } |
| |
1172 | decisionHandler(.cancel) |
| |
1173 | return |
| |
1174 | } |
| |
1175 | |
| |
1176 | if isAllowedReadmeNavigationURL(requestURL) { |
| |
1177 | decisionHandler(.allow) |
| |
1178 | } else { |
| |
1179 | decisionHandler(.cancel) |
| |
1180 | } |
| |
1181 | } |
| |
1182 | |
| 1091 | private func handleLoadFailure(_ error: Error) { |
1183 | private func handleLoadFailure(_ error: Error) { |
| 1092 | let nsError = error as NSError |
1184 | let nsError = error as NSError |
| 1093 | guard nsError.code != NSURLErrorCancelled else { return } |
1185 | guard nsError.code != NSURLErrorCancelled else { return } |
| @@ -1097,28 +1189,15 @@ private final class HTMLWebViewCoordinator: NSObject, WKNavigationDelegate, @unc |
| 1097 | } |
1189 | } |
| 1098 | |
1190 | |
| 1099 | private func updateHeight(for webView: WKWebView) { |
1191 | private func updateHeight(for webView: WKWebView) { |
| 1100 | let script = """ |
1192 | webView.layoutIfNeeded() |
| 1101 | Math.max( |
1193 | let height = ceil(max(webView.scrollView.contentSize.height, webView.sizeThatFits(.zero).height)) + 4 |
| 1102 | document.body.scrollHeight, |
1194 | guard height > 0 else { return } |
| 1103 | document.body.offsetHeight, |
1195 | DispatchQueue.main.async { |
| 1104 | document.documentElement.scrollHeight, |
1196 | if let html = self.lastHTML { |
| 1105 | document.documentElement.offsetHeight, |
1197 | Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) |
| 1106 | Math.ceil(document.body.getBoundingClientRect().height), |
1198 | } |
| 1107 | Math.ceil(document.documentElement.getBoundingClientRect().height) |
1199 | if abs(self.parent.dynamicHeight - height) > 0.5 { |
| 1108 | ) |
1200 | self.parent.dynamicHeight = height |
| 1109 | """ |
| |
| 1110 | |
| |
| 1111 | webView.evaluateJavaScript(script) { [weak self] result, _ in |
| |
| 1112 | guard let value = result as? Double, value > 0 else { return } |
| |
| 1113 | let height = ceil(value) + 4 |
| |
| 1114 | DispatchQueue.main.async { |
| |
| 1115 | guard let self else { return } |
| |
| 1116 | if let html = self.lastHTML { |
| |
| 1117 | Self.heightCache.setObject(NSNumber(value: Double(height)), forKey: html as NSString) |
| |
| 1118 | } |
| |
| 1119 | if abs(self.parent.dynamicHeight - height) > 0.5 { |
| |
| 1120 | self.parent.dynamicHeight = height |
| |
| 1121 | } |
| |
| 1122 | } |
1201 | } |
| 1123 | } |
1202 | } |
| 1124 | } |
1203 | } |