├── .github └── workflows │ └── swift.yml ├── .gitignore ├── Package.swift ├── README.md ├── Sources ├── Intermodular │ └── Extensions │ │ ├── Swift │ │ └── String++.swift │ │ ├── SwiftSoup │ │ └── SwiftSoup.Element++.swift │ │ └── WebKit │ │ └── WKWebView++.swift ├── Intramodular │ ├── Readability │ │ ├── Readability.swift │ │ ├── _ExtractedReadableContent.swift │ │ ├── _PostlightParserJS.swift │ │ ├── _ReadabilityJS.swift │ │ ├── _ReadableSiteMetadata.swift │ │ ├── _ShowdownJS.swift │ │ └── _TurndownJS.swift │ ├── SSE │ │ ├── SSESource.swift │ │ ├── SSEStreamParser.swift │ │ └── ServerSentEvent.swift │ ├── SwiftUI │ │ ├── WebView.swift │ │ ├── WebViewInternals.swift │ │ └── WebViewReader.swift │ ├── URLRequest++.swift │ ├── WKNavigation.Success.swift │ ├── _BKWebView+ WKScriptMessageHandler.swift │ ├── _BKWebView+Cookies.swift │ ├── _BKWebView+Interception.swift │ ├── _BKWebView+JS.swift │ ├── _BKWebView+Logging.swift │ ├── _BKWebView+MutationObserver.swift │ ├── _BKWebView+NetworkEvent.swift │ ├── _BKWebView+NetworkMessagePattern.swift │ ├── _BKWebView+NetworkMessagePublisher.swift │ ├── _BKWebView+Utilities.swift │ ├── _BKWebView+WKNavigationDelegate.swift │ └── _BKWebView.swift ├── Resources │ ├── mercury.web.js │ ├── readability.bundle.min.js │ ├── showdown.min.js │ └── turndown.js └── module.swift └── Tests ├── GeneralTests.swift ├── _TurndownJS.swift └── main.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | # This workflow will build a Swift project 5 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 6 | 7 | name: Build all  platforms 8 | 9 | on: 10 | push: 11 | branches: [ main ] 12 | pull_request: 13 | branches: [ main ] 14 | workflow_dispatch: 15 | schedule: 16 | - cron: 0 0 * * * 17 | 18 | concurrency: 19 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 20 | cancel-in-progress: true 21 | 22 | jobs: 23 | build_and_test: 24 | name: ${{ matrix.command }} on  ${{ matrix.platform }} (xcode ${{ matrix.xcode }}, ${{ matrix.macos }}) 25 | runs-on: ${{ matrix.macos }} 26 | strategy: 27 | fail-fast: false 28 | matrix: 29 | xcode: ['latest'] 30 | macos: ['macos-14'] 31 | scheme: ['BrowserKit'] 32 | command: ['build'] 33 | platform: ['macOS'] # ADD 'iOS', 'tvOS', 'watchOS' 34 | steps: 35 | - name: Switch xcode to ${{ matrix.xcode }} 36 | uses: maxim-lobanov/setup-xcode@v1.5.1 37 | with: 38 | xcode-version: ${{ matrix.xcode }} 39 | - name: Double-check macOS version (${{ matrix.macos }}) 40 | run: sw_vers 41 | - name: Code Checkout 42 | uses: actions/checkout@v2 43 | - name: Check xcodebuild version 44 | run: xcodebuild -version 45 | - name: Check xcode embedded SDKs 46 | run: xcodebuild -showsdks 47 | - name: Show buildable schemes 48 | run: xcodebuild -list 49 | - name: Show eligible build destinations for ${{ matrix.scheme }} 50 | run: xcodebuild -showdestinations -scheme ${{ matrix.scheme }} 51 | - name: Skip Macro Validation 52 | run: defaults write com.apple.dt.Xcode IDESkipMacroFingerprintValidation -bool YES 53 | - uses: mxcl/xcodebuild@v2.0.0 54 | with: 55 | platform: ${{ matrix.platform }} 56 | scheme: ${{ matrix.scheme }} 57 | action: ${{ matrix.command }} 58 | code-coverage: true 59 | verbosity: xcpretty 60 | upload-logs: always 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .swiftpm/ 3 | .swiftpm/* 4 | /*.xcodeproj 5 | /.build 6 | /Packages 7 | xcuserdata/ 8 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.10 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "BrowserKit", 7 | platforms: [ 8 | .iOS(.v16), 9 | .macOS(.v13) 10 | ], 11 | products: [ 12 | .library( 13 | name: "BrowserKit", 14 | targets: ["BrowserKit"] 15 | ) 16 | ], 17 | dependencies: [ 18 | .package(url: "https://github.com/preternatural-fork/SwiftSoup.git", branch: "master"), 19 | .package(url: "https://github.com/SwiftUIX/SwiftUIX.git", branch: "master"), 20 | .package(url: "https://github.com/vmanot/CorePersistence.git", branch: "main"), 21 | .package(url: "https://github.com/vmanot/NetworkKit.git", branch: "master"), 22 | .package(url: "https://github.com/vmanot/Swallow.git", branch: "master"), 23 | ], 24 | targets: [ 25 | .target( 26 | name: "BrowserKit", 27 | dependencies: [ 28 | "CorePersistence", 29 | "NetworkKit", 30 | "Swallow", 31 | "SwiftSoup", 32 | "SwiftUIX", 33 | ], 34 | path: "Sources", 35 | resources: [.process("Resources")], 36 | swiftSettings: [ 37 | .enableExperimentalFeature("AccessLevelOnImport") 38 | ] 39 | ), 40 | .testTarget( 41 | name: "BrowserKitTests", 42 | dependencies: [ 43 | "BrowserKit" 44 | ], 45 | path: "Tests" 46 | ) 47 | ] 48 | ) 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BrowserKit 2 | 3 | [![Build all  platforms](https://github.com/vmanot/BrowserKit/actions/workflows/swift.yml/badge.svg)](https://github.com/vmanot/BrowserKit/actions/workflows/swift.yml) 4 | 5 | # Requirements 6 | 7 | - Deployment target: iOS 16, macOS 13 8 | - Xcode 15+ 9 | 10 | # Usage 11 | 12 | The main export of this package is `BKWebView`. 13 | 14 | ## Bundled JavaScript libraries 15 | 16 | ### [`turndown.js`](https://github.com/mixmark-io/turndown) 17 | 18 | turndown is an HTML to Markdown converter written in JavaScript. BrowserKit ships a minified version of `turndown.js` that makes it easy to scrape web pages using a modern Swift API: 19 | 20 | For example: 21 | ```swift 22 | let turndown = await _TurndownJS() 23 | 24 | let urlRequest = URLRequest(url: URL(string: "https://en.wikipedia.org/wiki/Web_scraping")!) 25 | let htmlString = try await URLSession.shared.data(for: urlRequest).0.toString() 26 | 27 | let markdown = try await turndown.convert(htmlString: htmlString) 28 | 29 | print(markdown) 30 | ``` 31 | -------------------------------------------------------------------------------- /Sources/Intermodular/Extensions/Swift/String++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Foundation 6 | import Swallow 7 | 8 | extension String { 9 | @_spi(Internal) 10 | public func _encodeAsTopLevelJSON() -> String { 11 | let data = try! JSONSerialization.data(withJSONObject: self, options: .fragmentsAllowed) 12 | 13 | return String(data: data, encoding: .utf8)! 14 | } 15 | } 16 | 17 | extension String { 18 | var wrappedInSelfCallingJSFunction: String { 19 | "(function() { \(self) })()" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Intermodular/Extensions/SwiftSoup/SwiftSoup.Element++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Swallow 6 | import SwiftSoup 7 | 8 | extension SwiftSoup.Element { 9 | func firstChild( 10 | tag: String? = nil, 11 | id: String? = nil 12 | ) -> Element? { 13 | self.children().first { 14 | if let id { 15 | guard $0.id() == id else { 16 | return false 17 | } 18 | } 19 | 20 | if let tag { 21 | guard $0.tag().toString() == tag else { 22 | return false 23 | } 24 | } 25 | 26 | return true 27 | } 28 | } 29 | 30 | func firstAttribute( 31 | selector: String, 32 | attribute: String 33 | ) -> String? { 34 | try? select(selector).first(byUnwrapping: { try? $0.attr(attribute) }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sources/Intermodular/Extensions/WebKit/WKWebView++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import Swift 8 | import WebKit 9 | 10 | extension WKWebView { 11 | /// Loads the web content that the specified URL references and navigates to that content. 12 | public func load(_ url: URL) { 13 | load(URLRequest(url: url)) 14 | } 15 | } 16 | 17 | #endif 18 | -------------------------------------------------------------------------------- /Sources/Intramodular/Readability/Readability.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Diagnostics 6 | import Foundation 7 | import Swallow 8 | 9 | public final class Readability: Logging { 10 | public enum Engine: Equatable { 11 | case mercury 12 | case readability 13 | } 14 | 15 | public let engine: Engine 16 | 17 | public init(engine: Engine) { 18 | self.engine = engine 19 | } 20 | 21 | public func extract( 22 | from url: URL 23 | ) async throws -> Output { 24 | let (data, response) = try await URLSession.shared.data(from: url) 25 | 26 | let html = try String(data: data, encoding: .utf8).unwrap() 27 | let baseURL = response.url ?? url 28 | let content = try await extract(from: baseURL, htmlString: html) 29 | let metadata = try? _ReadableSiteMetadata(htmlString: html, baseURL: baseURL) 30 | 31 | return .init( 32 | metadata: metadata, 33 | content: content 34 | ) 35 | } 36 | 37 | public func extract( 38 | from url: URL, 39 | htmlString: String 40 | ) async throws -> _ExtractedReadableContent { 41 | switch engine { 42 | case .mercury: 43 | return try await _PostlightParserJS().extract(from: url, htmlString: htmlString) 44 | case .readability: 45 | return try await _ReadabilityJS().extract(from: url, htmlString: htmlString) 46 | } 47 | } 48 | } 49 | 50 | extension Readability { 51 | public struct Output { 52 | public var metadata: _ReadableSiteMetadata? 53 | public var content: _ExtractedReadableContent 54 | 55 | public var title: String? { 56 | content.title?.nilIfEmpty() ?? metadata?.title?.nilIfEmpty() 57 | } 58 | 59 | public var author: String? { 60 | content.author 61 | } 62 | 63 | public var excerpt: String? { 64 | content.excerpt ?? metadata?.description 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sources/Intramodular/Readability/_ExtractedReadableContent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Diagnostics 6 | import Foundation 7 | import Swallow 8 | 9 | public struct _ExtractedReadableContent: Equatable { 10 | // See https://github.com/postlight/mercury-parser#usage 11 | public var htmlString: String 12 | public var author: String? 13 | public var title: String? 14 | public var excerpt: String? 15 | } 16 | -------------------------------------------------------------------------------- /Sources/Intramodular/Readability/_PostlightParserJS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Diagnostics 6 | import Foundation 7 | import Swallow 8 | import WebKit 9 | 10 | @MainActor 11 | public class _PostlightParserJS: Logging { 12 | private let webView = _BKWebView() 13 | 14 | public init() async throws { 15 | let js = try String(contentsOf: Bundle.module.url(forResource: "mercury.web", withExtension: "js")!) 16 | 17 | try await webView.loadScript(js) 18 | } 19 | 20 | public func extract( 21 | from url: URL, 22 | htmlString: String 23 | ) async throws -> _ExtractedReadableContent { 24 | let script = "return await Mercury.parse(\(url.absoluteString._encodeAsTopLevelJSON()), {html: \(htmlString._encodeAsTopLevelJSON())})" 25 | 26 | let result = try await self.webView 27 | .callAsyncJavaScript(script, arguments: [:], in: nil, contentWorld: .page) 28 | .map({ try cast($0, to: [String: Any].self) }) 29 | 30 | return try self.parse(from: result).unwrap() 31 | } 32 | 33 | private func parse( 34 | from result: [String: Any]? 35 | ) throws -> _ExtractedReadableContent? { 36 | guard let result else { 37 | return nil 38 | } 39 | 40 | return try _ExtractedReadableContent( 41 | htmlString: (result["content"] as? String).unwrap(), 42 | author: result["author"] as? String, 43 | title: result["title"] as? String, 44 | excerpt: result["excerpt"] as? String 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Intramodular/Readability/_ReadabilityJS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Diagnostics 6 | import Foundation 7 | import Swallow 8 | import WebKit 9 | 10 | @MainActor 11 | public class _ReadabilityJS: Logging { 12 | private let webView = _BKWebView() 13 | 14 | public init() async throws { 15 | let js = try String(contentsOf: Bundle.module.url(forResource: "readability.bundle.min", withExtension: "js")!) 16 | 17 | try await webView.loadScript(js) 18 | } 19 | 20 | public func extract( 21 | from url: URL, 22 | htmlString: String 23 | ) async throws -> _ExtractedReadableContent { 24 | let script = "return await parse(\(htmlString._encodeAsTopLevelJSON()), \(url.absoluteString._encodeAsTopLevelJSON()))" 25 | 26 | let result = try await self.webView 27 | .callAsyncJavaScript(script, arguments: [:], in: nil, contentWorld: .page) 28 | .map({ try cast($0, to: [String: Any].self) }) 29 | 30 | return try self.parse(from: result).unwrap() 31 | } 32 | 33 | private func parse( 34 | from result: [String: Any]? 35 | ) throws -> _ExtractedReadableContent? { 36 | guard let result else { 37 | return nil 38 | } 39 | 40 | return try _ExtractedReadableContent( 41 | htmlString: (result["content"] as? String).unwrap(), 42 | author: result["author"] as? String, 43 | title: result["title"] as? String, 44 | excerpt: result["excerpt"] as? String 45 | ) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/Intramodular/Readability/_ReadableSiteMetadata.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Foundation 6 | internal import Merge 7 | import Swallow 8 | import SwiftSoup 9 | import WebKit 10 | 11 | public struct _ReadableSiteMetadata: Equatable, Codable { 12 | public var url: URL 13 | public var title: String? 14 | public var description: String? 15 | public var heroImage: URL? 16 | public var favicon: URL? 17 | 18 | public init( 19 | htmlString: String, 20 | baseURL: URL 21 | ) throws { 22 | let document = try SwiftSoup.parse(htmlString) 23 | 24 | self.url = baseURL 25 | self.title = try? document.ogTitle ?? document.title 26 | self.heroImage = document.ogImage(baseURL: baseURL) 27 | self.description = document.metaDescription.nilIfEmpty() 28 | self.favicon = try? document.favicon(baseURL: baseURL) ?? baseURL.inferredFaviconURL 29 | } 30 | } 31 | 32 | // MARK: - Auxiliary 33 | 34 | extension SwiftSoup.Document { 35 | fileprivate var title: String? { 36 | get throws { 37 | try firstChild(tag: "head")?.firstChild(tag: "title")?.val() 38 | } 39 | } 40 | 41 | fileprivate var metaDescription: String? { 42 | firstAttribute(selector: "meta[name='description']", attribute: "content") 43 | } 44 | 45 | fileprivate var ogTitle: String? { 46 | firstAttribute(selector: "meta[property='og:title']", attribute: "content") 47 | } 48 | 49 | fileprivate func ogImage(baseURL: URL) -> URL? { 50 | if let link = firstAttribute(selector: "meta[property='og:image']", attribute: "content") { 51 | return URL(string: link, relativeTo: baseURL) 52 | } 53 | 54 | return nil 55 | } 56 | 57 | fileprivate func favicon(baseURL: URL) throws -> URL? { 58 | for item in try select("link") { 59 | if let rel = try? item.attr("rel"), 60 | (rel == "icon" || rel == "shortcut icon"), 61 | let val = try? item.attr("href"), 62 | let resolved = URL(string: val, relativeTo: baseURL) 63 | { 64 | return resolved 65 | } 66 | } 67 | 68 | return nil 69 | } 70 | } 71 | 72 | // MARK: - Auxiliary 73 | 74 | extension URL { 75 | fileprivate var inferredFaviconURL: URL { 76 | return URL(string: "/favicon.ico", relativeTo: self)! 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/Intramodular/Readability/_ShowdownJS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Diagnostics 6 | import Swallow 7 | import WebKit 8 | 9 | @MainActor 10 | public final class _ShowdownJS { 11 | private static let javascript = try! String(contentsOf: Bundle.module.url(forResource: "showdown.min", withExtension: "js")!) 12 | 13 | private let webView = _BKWebView() 14 | 15 | public init() { 16 | 17 | } 18 | 19 | public func convert(_ html: String) async throws -> String { 20 | guard !html.isEmpty else { 21 | return html 22 | } 23 | 24 | try await webView.load() 25 | 26 | let html = encloseInHTMLTags(html) 27 | 28 | let base64HtmlString = try html.data(using: .utf8, allowLossyConversion: false) 29 | .unwrap() 30 | .base64EncodedString() 31 | 32 | try await webView.loadScript(Self.javascript) 33 | 34 | let script = """ 35 | var converter = new showdown.Converter(); 36 | var html = atob('\(base64HtmlString)'); 37 | return converter.makeMarkdown(html); 38 | """ 39 | 40 | let result = try cast(try await webView.callAsyncJavaScript(script, contentWorld: .page), to: String.self) 41 | 42 | return result 43 | } 44 | 45 | func encloseInHTMLTags(_ html: String) -> String { 46 | if let _ = html.range(of: "]*>.*", options: .regularExpression, range: nil, locale: nil) { 47 | return html 48 | } else { 49 | return "\(html)" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Sources/Intramodular/Readability/_TurndownJS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Diagnostics 6 | import Swallow 7 | import WebKit 8 | 9 | /// BrowserKit's convenience wrapper over `turndown`. 10 | /// 11 | /// turndown.js is a JavaScript library that converts HTML into Markdown. 12 | /// 13 | /// References: 14 | /// - https://github.com/mixmark-io/turndown 15 | @MainActor 16 | public final class _TurndownJS { 17 | private let js = try! String(contentsOf: Bundle.module.url(forResource: "turndown", withExtension: "js")!) 18 | 19 | private let webView = _BKWebView() 20 | 21 | public init() { 22 | 23 | } 24 | 25 | /// Converts the given HTML into Markdown. 26 | public func convert( 27 | htmlString: String 28 | ) async throws -> String { 29 | guard !htmlString.isEmpty else { 30 | return htmlString 31 | } 32 | 33 | let base64HtmlString = try htmlString.data(using: .utf8, allowLossyConversion: false) 34 | .unwrap() 35 | .base64EncodedString() 36 | 37 | try await webView.loadScript(js) 38 | 39 | let script = """ 40 | var turndownService = new TurndownService(); 41 | var html = decodeURIComponent(atob('\(base64HtmlString)').split('').map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join('')); 42 | return turndownService.turndown(html); 43 | """ 44 | 45 | let result = try await webView.callAsyncJavaScript(script, contentWorld: .page) 46 | 47 | return try cast(result, to: String.self) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/Intramodular/SSE/SSESource.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Swallow 6 | 7 | public enum SSESourceState { 8 | case connecting 9 | case open 10 | case closed 11 | } 12 | 13 | open class SSESource: NSObject, URLSessionDataDelegate { 14 | static let DefaultRetryTime = 3000 15 | 16 | public let urlRequest: URLRequest 17 | 18 | private(set) public var lastEventId: String? 19 | private(set) public var retryTime = SSESource.DefaultRetryTime 20 | private(set) public var headers: [String: String] 21 | private(set) public var readyState: SSESourceState 22 | 23 | private var onOpenCallback: (() -> Void)? 24 | private var onComplete: ((Int?, Bool?, NSError?) -> Void)? 25 | private var onMessageCallback: ((_ id: String?, _ event: String?, _ data: String?) -> Void)? 26 | private var eventListeners: [String: (_ id: String?, _ event: String?, _ data: String?) -> Void] = [:] 27 | 28 | private var eventStreamParser: SSEStreamParser? 29 | private var operationQueue: OperationQueue 30 | private var mainQueue = DispatchQueue.main 31 | private var urlSession: URLSession? 32 | 33 | public init( 34 | urlRequest: URLRequest 35 | ) { 36 | self.urlRequest = urlRequest 37 | self.headers = urlRequest.allHTTPHeaderFields ?? [:] 38 | 39 | readyState = SSESourceState.closed 40 | operationQueue = OperationQueue() 41 | operationQueue.maxConcurrentOperationCount = 1 42 | 43 | super.init() 44 | } 45 | 46 | public func connect(lastEventId: String? = nil) { 47 | eventStreamParser = SSEStreamParser() 48 | readyState = .connecting 49 | 50 | let configuration = sessionConfiguration(lastEventId: lastEventId) 51 | urlSession = URLSession(configuration: configuration, delegate: self, delegateQueue: operationQueue) 52 | urlSession?.dataTask(with: urlRequest.url!).resume() 53 | } 54 | 55 | public func disconnect() { 56 | readyState = .closed 57 | urlSession?.invalidateAndCancel() 58 | } 59 | 60 | public func onOpen( 61 | _ onOpenCallback: @escaping (() -> Void) 62 | ) { 63 | self.onOpenCallback = onOpenCallback 64 | } 65 | 66 | public func onComplete( 67 | _ onComplete: @escaping ((Int?, Bool?, NSError?) -> Void) 68 | ) { 69 | self.onComplete = onComplete 70 | } 71 | 72 | public func onMessage( 73 | _ onMessageCallback: @escaping ((_ id: String?, _ event: String?, _ data: String?) -> Void) 74 | ) { 75 | self.onMessageCallback = onMessageCallback 76 | } 77 | 78 | public func addEventListener( 79 | _ event: String, 80 | handler: @escaping ((_ id: String?, _ event: String?, _ data: String?) -> Void) 81 | ) { 82 | eventListeners[event] = handler 83 | } 84 | 85 | public func removeEventListener(_ event: String) { 86 | eventListeners.removeValue(forKey: event) 87 | } 88 | 89 | public func events() -> [String] { 90 | return Array(eventListeners.keys) 91 | } 92 | 93 | open func urlSession( 94 | _ session: URLSession, 95 | dataTask: URLSessionDataTask, 96 | didReceive data: Data 97 | ) { 98 | if readyState != .open { 99 | return 100 | } 101 | 102 | if let events = eventStreamParser?.append(data: data) { 103 | notifyReceivedEvents(events) 104 | } 105 | } 106 | 107 | open func urlSession( 108 | _ session: URLSession, 109 | dataTask: URLSessionDataTask, 110 | didReceive response: URLResponse, 111 | completionHandler: @escaping (URLSession.ResponseDisposition) -> Void 112 | ) { 113 | completionHandler(URLSession.ResponseDisposition.allow) 114 | 115 | readyState = .open 116 | 117 | mainQueue.async { 118 | [weak self] in self?.onOpenCallback?() 119 | } 120 | } 121 | 122 | open func urlSession( 123 | _ session: URLSession, 124 | task: URLSessionTask, 125 | didCompleteWithError error: Error? 126 | ) { 127 | guard let responseStatusCode = (task.response as? HTTPURLResponse)?.statusCode else { 128 | mainQueue.async { [weak self] in self?.onComplete?(nil, nil, error as NSError?) } 129 | return 130 | } 131 | 132 | let reconnect = shouldReconnect(statusCode: responseStatusCode) 133 | 134 | mainQueue.async { 135 | [weak self] in self?.onComplete?(responseStatusCode, reconnect, nil) 136 | } 137 | } 138 | 139 | open func urlSession( 140 | _ session: URLSession, 141 | task: URLSessionTask, 142 | willPerformHTTPRedirection response: HTTPURLResponse, 143 | newRequest request: URLRequest, 144 | completionHandler: @escaping (URLRequest?) -> Void 145 | ) { 146 | var newRequest = request 147 | 148 | self.headers.forEach { 149 | newRequest.setValue($1, forHTTPHeaderField: $0) 150 | } 151 | 152 | completionHandler(newRequest) 153 | } 154 | } 155 | 156 | extension SSESource { 157 | func sessionConfiguration( 158 | lastEventId: String? 159 | ) -> URLSessionConfiguration { 160 | var additionalHeaders = headers 161 | 162 | if let eventID = lastEventId { 163 | additionalHeaders["Last-Event-Id"] = eventID 164 | } 165 | 166 | additionalHeaders["Accept"] = "text/event-stream" 167 | additionalHeaders["Cache-Control"] = "no-cache" 168 | 169 | let sessionConfiguration = URLSessionConfiguration.default 170 | 171 | sessionConfiguration.timeoutIntervalForRequest = TimeInterval(INT_MAX) 172 | sessionConfiguration.timeoutIntervalForResource = TimeInterval(INT_MAX) 173 | sessionConfiguration.httpAdditionalHeaders = additionalHeaders 174 | 175 | return sessionConfiguration 176 | } 177 | 178 | func readyStateOpen() { 179 | readyState = .open 180 | } 181 | } 182 | 183 | // MARK: - Auxiliary 184 | 185 | private extension SSESource { 186 | func notifyReceivedEvents(_ events: [ServerSentEvent]) { 187 | for event in events { 188 | lastEventId = event.id 189 | retryTime = event.retryTime ?? SSESource.DefaultRetryTime 190 | 191 | if event.onlyRetryEvent == true { 192 | continue 193 | } 194 | 195 | if event.event == nil || event.event == "message" { 196 | mainQueue.async { [weak self] in self?.onMessageCallback?(event.id, "message", event.data) } 197 | } 198 | 199 | if let eventName = event.event, let eventHandler = eventListeners[eventName] { 200 | mainQueue.async { eventHandler(event.id, event.event, event.data) } 201 | } 202 | } 203 | } 204 | 205 | // Following "5 Processing model" from: 206 | // https://www.w3.org/TR/2009/WD-eventsource-20090421/#handler-eventsource-onerror 207 | func shouldReconnect(statusCode: Int) -> Bool { 208 | switch statusCode { 209 | case 200: 210 | return false 211 | case _ where statusCode > 200 && statusCode < 300: 212 | return true 213 | default: 214 | return false 215 | } 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /Sources/Intramodular/SSE/SSEStreamParser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Swallow 6 | 7 | final class SSEStreamParser { 8 | // ["\r\n", "\n", "\r"] 9 | private let validNewlineCharacters: [Character] = ["\n", "\r"] 10 | private let dataBuffer: NSMutableData 11 | 12 | init() { 13 | dataBuffer = NSMutableData() 14 | } 15 | 16 | var currentBuffer: String? { 17 | return NSString(data: dataBuffer as Data, encoding: String.Encoding.utf8.rawValue) as String? 18 | } 19 | 20 | func append(data: Data?) -> [ServerSentEvent] { 21 | guard let data = data else { return [] } 22 | dataBuffer.append(data) 23 | 24 | let events = extractEventsFromBuffer().compactMap { [weak self] eventString -> ServerSentEvent? in 25 | guard let self = self else { 26 | return nil 27 | } 28 | 29 | return ServerSentEvent( 30 | eventString: eventString, 31 | newLineCharacters: self.validNewlineCharacters 32 | ) 33 | } 34 | 35 | return events 36 | } 37 | 38 | private func extractEventsFromBuffer() -> [String] { 39 | var events = [String]() 40 | 41 | var searchRange = NSRange(location: 0, length: dataBuffer.length) 42 | while let foundRange = searchFirstEventDelimiter(in: searchRange) { 43 | // if we found a delimiter range that means that from the beggining of the buffer 44 | // until the beggining of the range where the delimiter was found we have an event. 45 | // The beggining of the event is: searchRange.location 46 | // The lenght of the event is the position where the foundRange was found. 47 | 48 | let dataChunk = dataBuffer.subdata( 49 | with: NSRange(location: searchRange.location, length: foundRange.location - searchRange.location) 50 | ) 51 | 52 | if let text = String(bytes: dataChunk, encoding: .utf8) { 53 | events.append(text) 54 | } 55 | 56 | // We move the searchRange start position (location) after the fundRange we just found and 57 | searchRange.location = foundRange.location + foundRange.length 58 | searchRange.length = dataBuffer.length - searchRange.location 59 | } 60 | 61 | // We empty the piece of the buffer we just search in. 62 | dataBuffer.replaceBytes(in: NSRange(location: 0, length: searchRange.location), withBytes: nil, length: 0) 63 | 64 | return events 65 | } 66 | 67 | // This methods returns the range of the first delimiter found in the buffer. For example: 68 | // If in the buffer we have: `id: event-id-1\ndata:event-data-first\n\n` 69 | // This method will return the range for the `\n\n`. 70 | private func searchFirstEventDelimiter( 71 | in range: NSRange 72 | ) -> NSRange? { 73 | let delimiters = validNewlineCharacters.map { 74 | "\($0)\($0)".data(using: String.Encoding.utf8)! 75 | } 76 | 77 | for delimiter in delimiters { 78 | let foundRange = dataBuffer.range( 79 | of: delimiter, options: NSData.SearchOptions(), in: range 80 | ) 81 | 82 | if foundRange.location != NSNotFound { 83 | return foundRange 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Sources/Intramodular/SSE/ServerSentEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Swallow 6 | 7 | /// Represents a Server-Sent Event. 8 | /// 9 | /// Server-Sent Events (SSE) is a server push technology enabling a client to receive automatic updates from a server via HTTP connection. 10 | public enum ServerSentEvent { 11 | /// Represents an event with optional id, event name, data, and retry time. 12 | case event(id: String?, event: String?, data: String?, retry: String?) 13 | 14 | /// Creates a `ServerSentEvent` from a string representation. 15 | /// 16 | /// - Parameters: 17 | /// - eventString: The string representation of the event. 18 | /// - newLineCharacters: Characters to be considered as new lines. 19 | /// - Returns: A `ServerSentEvent` if the string is valid, `nil` otherwise. 20 | public init?(eventString: String?, newLineCharacters: [Character]) { 21 | guard let eventString = eventString, !eventString.hasPrefix(":") else { return nil } 22 | self = ServerSentEvent.parseEvent(eventString, newLineCharacters: newLineCharacters) 23 | } 24 | 25 | /// The ID of the event. 26 | public var id: String? { 27 | guard case let .event(id, _, _, _) = self else { return nil } 28 | return id 29 | } 30 | 31 | /// The name of the event. 32 | public var event: String? { 33 | guard case let .event(_, event, _, _) = self else { return nil } 34 | return event 35 | } 36 | 37 | /// The data associated with the event. 38 | public var data: String? { 39 | guard case let .event(_, _, data, _) = self else { return nil } 40 | return data 41 | } 42 | 43 | /// The retry time of the event, if specified. 44 | public var retryTime: Int? { 45 | guard case let .event(_, _, _, retry) = self, let retry = retry else { return nil } 46 | return Int(retry.trimmingCharacters(in: .whitespaces)) 47 | } 48 | 49 | /// Indicates if the event only contains retry information. 50 | public var onlyRetryEvent: Bool { 51 | guard case let .event(id, event, data, retry) = self else { return false } 52 | return id == nil && event == nil && data == nil && retry != nil 53 | } 54 | } 55 | 56 | private extension ServerSentEvent { 57 | static func parseEvent( 58 | _ eventString: String, 59 | newLineCharacters: [Character] 60 | ) -> ServerSentEvent { 61 | var event: [String: String] = [:] 62 | let lines = eventString.components(separatedBy: .newlines) 63 | 64 | for (index, line) in lines.enumerated() { 65 | let (key, value) = parseLine(line, index: index, lines: lines, newLineCharacters: newLineCharacters) 66 | if let key = key, let value = value { 67 | event[key] = event[key].map { $0 + "\n" + value } ?? value 68 | } 69 | } 70 | 71 | return .event(id: event["id"], event: event["event"], data: event["data"], retry: event["retry"]) 72 | } 73 | 74 | static func parseLine( 75 | _ line: String, 76 | index: Int, 77 | lines: [String], 78 | newLineCharacters: [Character] 79 | ) -> (key: String?, value: String?) { 80 | let components = line.components(separatedBy: ":") 81 | guard components.count > 1 else { 82 | return (nil, index == lines.count - 1 ? line.trimmingCharacters(in: .whitespaces) : nil) 83 | } 84 | 85 | let key = components[0].trimmingCharacters(in: .whitespaces) 86 | var value = components[1].trimmingCharacters(in: .whitespaces) 87 | 88 | if value.isEmpty, index < lines.count - 1 { 89 | value = lines[(index + 1)...].joined(separator: "\n") 90 | .components(separatedBy: CharacterSet(newLineCharacters))[0] 91 | .trimmingCharacters(in: .whitespaces) 92 | } 93 | 94 | return (key, value) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Sources/Intramodular/SwiftUI/WebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import Swift 8 | import SwiftUI 9 | internal import SwiftUIX 10 | import WebKit 11 | 12 | public struct WebView: View { 13 | private let configuration: _BKWebViewRepresentable.Configuration 14 | 15 | @State private var state = _WebViewState() 16 | 17 | public var body: some View { 18 | _BKWebViewRepresentable(configuration: configuration, state: $state) 19 | } 20 | } 21 | 22 | extension WebView { 23 | public init(underlyingView: _BKWebView) { 24 | self.configuration = .init(underlyingView: underlyingView) 25 | } 26 | 27 | public init( 28 | url: URL, 29 | delegate: WebViewDelegate? = nil 30 | ) { 31 | self.configuration = .init(url: url, delegate: delegate) 32 | } 33 | 34 | public init( 35 | url: String, 36 | delegate: WebViewDelegate? = nil 37 | ) { 38 | self.init(url: URL(string: url)!, delegate: delegate) 39 | } 40 | } 41 | 42 | #endif 43 | -------------------------------------------------------------------------------- /Sources/Intramodular/SwiftUI/WebViewInternals.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import Swift 8 | import SwiftUI 9 | internal import SwiftUIX 10 | import WebKit 11 | 12 | struct _WebViewConfiguration { 13 | var underlyingView: _BKWebView? 14 | var url: URL? 15 | var delegate: WebViewDelegate? 16 | } 17 | 18 | public protocol WebViewDelegate { 19 | func cookiesUpdated(_ cookies: [HTTPCookie]) 20 | } 21 | 22 | public struct _WebViewState { 23 | var isLoading: Bool? 24 | } 25 | 26 | extension WebView { 27 | struct _BKWebViewRepresentable: AppKitOrUIKitViewRepresentable { 28 | public typealias AppKitOrUIKitViewType = _BKWebView 29 | 30 | typealias Configuration = _WebViewConfiguration 31 | 32 | let configuration: Configuration 33 | 34 | @Binding var state: _WebViewState 35 | 36 | func makeAppKitOrUIKitView(context: Context) -> AppKitOrUIKitViewType { 37 | if let underlyingView = configuration.underlyingView { 38 | return underlyingView 39 | } 40 | 41 | let view = _BKWebView() 42 | 43 | if let url = configuration.url { 44 | view.load(url) 45 | } 46 | 47 | return view 48 | } 49 | 50 | func updateAppKitOrUIKitView(_ view: AppKitOrUIKitViewType, context: Context) { 51 | func updateWebViewProxy() { 52 | if let _webViewProxy = context.environment._webViewProxy { 53 | if _webViewProxy.wrappedValue.base !== view { 54 | DispatchQueue.main.async { 55 | _webViewProxy.wrappedValue.base = view 56 | } 57 | } 58 | } 59 | } 60 | 61 | view.coordinator = context.coordinator 62 | 63 | context.coordinator.configuration = configuration 64 | context.coordinator._updateStateBinding($state) 65 | 66 | updateWebViewProxy() 67 | } 68 | 69 | static func dismantleAppKitOrUIKitView(_ view: AppKitOrUIKitViewType, coordinator: Coordinator) { 70 | 71 | } 72 | 73 | func makeCoordinator() -> Coordinator { 74 | Coordinator(configuration: configuration, state: $state) 75 | } 76 | } 77 | } 78 | 79 | extension WebView._BKWebViewRepresentable { 80 | class Coordinator: NSObject, ObservableObject, WKNavigationDelegate { 81 | var configuration: _WebViewConfiguration 82 | 83 | @Binding var state: _WebViewState 84 | 85 | var cookies: [HTTPCookie]? { 86 | didSet { 87 | if let cookies = cookies { 88 | configuration.delegate?.cookiesUpdated(cookies) 89 | } 90 | } 91 | } 92 | 93 | init(configuration: _WebViewConfiguration, state: Binding<_WebViewState>) { 94 | self.configuration = configuration 95 | self._state = state 96 | } 97 | 98 | fileprivate func _updateStateBinding(_ state: Binding<_WebViewState>) { 99 | self._state = state 100 | } 101 | } 102 | } 103 | 104 | #endif 105 | -------------------------------------------------------------------------------- /Sources/Intramodular/SwiftUI/WebViewReader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | #if os(iOS) || os(macOS) || os(visionOS) 6 | 7 | import Swift 8 | import SwiftUI 9 | internal import SwiftUIX 10 | import WebKit 11 | 12 | /// A view whose child is defined as a function of a `WebViewProxy` targeting the collection views within the child. 13 | public struct WebViewReader: View { 14 | @Environment(\._webViewProxy) var _environment_webViewProxy 15 | 16 | public let content: (WebViewProxy) -> Content 17 | 18 | @State private var _webViewProxy = WebViewProxy() 19 | 20 | public init( 21 | @ViewBuilder content: @escaping (WebViewProxy) -> Content 22 | ) { 23 | self.content = content 24 | } 25 | 26 | public var body: some View { 27 | content(_environment_webViewProxy?.wrappedValue ?? _webViewProxy) 28 | .environment(\._webViewProxy, $_webViewProxy) 29 | } 30 | } 31 | 32 | public struct WebViewProxy: Hashable { 33 | weak var base: _BKWebView? 34 | 35 | public var webView: _BKWebView? { 36 | base 37 | } 38 | } 39 | 40 | // MARK: - Auxiliary 41 | 42 | extension EnvironmentValues { 43 | struct WebViewProxyKey: EnvironmentKey { 44 | static let defaultValue: Binding? = nil 45 | } 46 | 47 | var _webViewProxy: Binding? { 48 | get { 49 | self[WebViewProxyKey.self] 50 | } set { 51 | self[WebViewProxyKey.self] = newValue 52 | } 53 | } 54 | } 55 | 56 | #endif 57 | -------------------------------------------------------------------------------- /Sources/Intramodular/URLRequest++.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // BrowserKit 4 | // 5 | // Created by Purav Manot on 26/05/25. 6 | // 7 | 8 | import Foundation 9 | import UniformTypeIdentifiers 10 | 11 | extension URLRequest { 12 | /// Best-effort MIME type declared for this request (`Content-Type` header). 13 | /// 14 | /// 1. Looks at the **Content-Type** header and strips any `; charset=`, etc. 15 | /// 2. If the header is absent, falls back to the file extension in the URL 16 | /// (e.g. “.json” → “application/json”) using `UniformTypeIdentifiers`. 17 | public var mimeType: String? { 18 | if let raw = value(forHTTPHeaderField: "Content-Type")?.split(separator: ";", maxSplits: 1).first { 19 | return raw.trimmingCharacters(in: .whitespaces) 20 | } 21 | 22 | if let pathExtension = url?.pathExtension, let utType = UTType(filenameExtension: pathExtension), let mime = utType.preferredMIMEType { 23 | return mime 24 | } 25 | 26 | return nil 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Intramodular/WKNavigation.Success.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) 6 | 7 | import WebKit 8 | 9 | extension WKNavigation { 10 | public struct Success: Sendable { 11 | public let urlResponse: URLResponse 12 | } 13 | } 14 | 15 | #endif 16 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+ WKScriptMessageHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Foundation 6 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+Cookies.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | internal import Merge 6 | import Swallow 7 | import WebKit 8 | 9 | extension _BKWebView { 10 | public func updateCookies(_ cookies: [HTTPCookie]?) async { 11 | guard let cookies = cookies, cookies.isEmpty == false else { 12 | return 13 | } 14 | 15 | await withTaskGroup(of: Void.self) { group in 16 | for cookie in cookies { 17 | group.addTask { 18 | await self.setCookie(cookie) 19 | } 20 | } 21 | } 22 | } 23 | 24 | public func setCookie(_ cookie: HTTPCookie) async { 25 | HTTPCookieStorage.shared.setCookie(cookie) 26 | 27 | await configuration.websiteDataStore.httpCookieStore.setCookie(cookie) 28 | } 29 | 30 | public func deleteCookie(_ cookie: HTTPCookie) async { 31 | HTTPCookieStorage.shared.deleteCookie(cookie) 32 | 33 | await configuration.websiteDataStore.httpCookieStore.deleteCookie(cookie) 34 | } 35 | 36 | public func deleteAllCookies() async { 37 | HTTPCookieStorage.shared.removeCookies(since: Date.distantPast) 38 | 39 | await configuration.websiteDataStore.httpCookieStore.deleteAllCookies() 40 | } 41 | 42 | func userContentController(for url: URL) -> WKUserContentController { 43 | let userContentController = configuration.userContentController 44 | 45 | guard let cookies = HTTPCookieStorage.shared.cookies(for: url), cookies.count > 0 else { 46 | return userContentController 47 | } 48 | 49 | // https://stackoverflow.com/a/32845148 50 | var scripts: [String] = ["var cookieNames = document.cookie.split('; ').map(function(cookie) { return cookie.split('=')[0] } )"] 51 | 52 | let now = Date() 53 | 54 | for cookie in cookies { 55 | if let expiresDate = cookie.expiresDate, now.compare(expiresDate) == .orderedDescending { 56 | Task { 57 | await deleteCookie(cookie) 58 | } 59 | 60 | continue 61 | } 62 | 63 | scripts.append("if (cookieNames.indexOf('\(cookie.name)') == -1) { document.cookie='\(cookie.javascriptString)'; }") 64 | } 65 | 66 | let mainScript = scripts.joined(separator: ";\n") 67 | 68 | userContentController.addUserScript( 69 | WKUserScript( 70 | source: mainScript, 71 | injectionTime: .atDocumentStart, 72 | forMainFrameOnly: false 73 | ) 74 | ) 75 | 76 | return userContentController 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+Interception.swift: -------------------------------------------------------------------------------- 1 | import Diagnostics 2 | import SwiftUI 3 | import Swallow 4 | import NetworkKit 5 | import WebKit 6 | 7 | extension _BKWebView { 8 | class NetworkScriptMessageHandler: NSObject, WKScriptMessageHandler { 9 | internal var handlers: IdentifierIndexingArrayOf = [] 10 | 11 | func userContentController( 12 | _ userContentController: WKUserContentController, 13 | didReceive message: WKScriptMessage 14 | ) { 15 | guard message.name == "network" else { return } 16 | 17 | guard let dict = message.body as? [String: Any] else { 18 | return 19 | } 20 | 21 | #try(.optimistic) { 22 | let data = try JSONSerialization.data(withJSONObject: dict) 23 | 24 | let networkMessage = try JSONDecoder().decode(_BKWebView.NetworkEvent.self, from: data) 25 | for handler in self.handlers { 26 | try handler(networkMessage) 27 | } 28 | } 29 | } 30 | } 31 | 32 | struct NetworkMessageHandler: Identifiable { 33 | var id = UUID() 34 | var action: (NetworkEvent) -> () 35 | var predicate: _BKWebView.NetworkMessagePattern 36 | 37 | init(id: UUID = UUID(), predicate: _BKWebView.NetworkMessagePattern, action: @escaping (NetworkEvent) -> Void) { 38 | self.id = id 39 | self.predicate = predicate 40 | self.action = action 41 | } 42 | 43 | func callAsFunction(_ message: NetworkEvent) throws { 44 | if try predicate.matches(message) { 45 | self.action(message) 46 | } 47 | } 48 | } 49 | } 50 | 51 | extension WKWebViewConfiguration { 52 | func install(handler: _BKWebView.NetworkScriptMessageHandler) { 53 | let networkInterceptor = #""" 54 | // == network-logger-wkwebview.js =================================== 55 | (function interceptNetworkTraffic() { 56 | /* ---------- helpers ----------------------------------------- */ 57 | function stringifySafely(value) { 58 | try { return JSON.stringify(value); } 59 | catch { return String(value); } 60 | } 61 | 62 | function toAbsoluteURL(maybeRelative) { 63 | try { return new URL(maybeRelative, window.location.href).href; } 64 | catch { return String(maybeRelative); } 65 | } 66 | 67 | async function cloneBodyText(response) { 68 | const contentType = response.headers.get("content-type") || ""; 69 | try { 70 | if (contentType.includes("json")) 71 | return stringifySafely(await response.clone().json()); 72 | if (contentType.includes("text") || contentType.includes("html")) 73 | return await response.clone().text(); 74 | return "[non-textual response]"; 75 | } catch { 76 | return "[unreadable response]"; 77 | } 78 | } 79 | 80 | function normalizeHeaders(input) { 81 | if (!input) return null; 82 | if (typeof Headers !== "undefined" && input instanceof Headers) 83 | return Object.fromEntries(input.entries()); 84 | if (Array.isArray(input)) 85 | return Object.fromEntries(input); 86 | if (typeof input === "object") 87 | return { ...input }; 88 | return null; 89 | } 90 | 91 | function parseRawHeaders(rawHeaders) { 92 | return (rawHeaders || "") 93 | .trim() 94 | .split(/[\r\n]+/) 95 | .reduce((headerMap, line) => { 96 | const [name, value] = line.split(": "); 97 | if (name) headerMap[name] = value; 98 | return headerMap; 99 | }, {}); 100 | } 101 | 102 | function sendMessage(payload) { 103 | try { 104 | window.webkit?.messageHandlers?.network?.postMessage(payload); 105 | } catch (postError) { 106 | console.error("network message failed:", postError, payload); 107 | } 108 | } 109 | 110 | /* ---------- fetch ------------------------------------------- */ 111 | const originalFetch = window.fetch; 112 | window.fetch = async (...callArguments) => { 113 | const [resource, config = {}] = callArguments; 114 | 115 | const resourceURL = (resource instanceof Request) 116 | ? resource.url 117 | : resource.toString(); 118 | 119 | const absoluteURL = toAbsoluteURL(resourceURL); 120 | 121 | sendMessage({ 122 | id: crypto.randomUUID(), 123 | source: "fetch", 124 | phase: "request", 125 | url: absoluteURL, 126 | httpMethod: config.method.toUpperCase() ?? "GET", 127 | httpBody: stringifySafely(config.body ?? null), 128 | headers: normalizeHeaders(config.headers) 129 | }); 130 | 131 | try { 132 | const response = await originalFetch(...callArguments); 133 | 134 | sendMessage({ 135 | id: crypto.randomUUID(), 136 | source: "fetch", 137 | phase: "response", 138 | url: absoluteURL, 139 | statusCode: response.status, 140 | mimeType: response.headers.get("content-type") ?? null, 141 | headers: normalizeHeaders(response.headers), 142 | body: await cloneBodyText(response) 143 | }); 144 | 145 | return response; 146 | } catch (networkError) { 147 | sendMessage({ 148 | id: crypto.randomUUID(), 149 | source: "fetch", 150 | phase: "error", 151 | url: absoluteURL, 152 | errorDescription: String(networkError) 153 | }); 154 | throw networkError; 155 | } 156 | }; 157 | 158 | /* ---------- XMLHttpRequest ---------------------------------- */ 159 | const OriginalXMLHttpRequest = window.XMLHttpRequest; 160 | function CustomXMLHttpRequest() { 161 | const xmlHttpRequest = new OriginalXMLHttpRequest(); 162 | 163 | let requestHeaders = {}; 164 | let requestMethod = "GET"; 165 | let rawRequestURL = ""; 166 | 167 | xmlHttpRequest.setRequestHeader = new Proxy(xmlHttpRequest.setRequestHeader, { 168 | apply(target, thisArg, argumentArray) { 169 | const [headerName, headerValue] = argumentArray; 170 | requestHeaders[headerName] = headerValue; 171 | return target.apply(thisArg, argumentArray); 172 | } 173 | }); 174 | 175 | xmlHttpRequest.open = new Proxy(xmlHttpRequest.open, { 176 | apply(target, thisArg, argumentArray) { 177 | [requestMethod, rawRequestURL] = argumentArray; 178 | return target.apply(thisArg, argumentArray); 179 | } 180 | }); 181 | 182 | xmlHttpRequest.send = new Proxy(xmlHttpRequest.send, { 183 | apply(target, thisArg, argumentArray) { 184 | const requestBody = argumentArray[0]; 185 | const absoluteURL = toAbsoluteURL(rawRequestURL); 186 | 187 | sendMessage({ 188 | id: crypto.randomUUID(), 189 | source: "xhr", 190 | phase: "request", 191 | url: absoluteURL, 192 | httpMethod: requestMethod.toUpperCase(), 193 | httpBody: stringifySafely(requestBody ?? null), 194 | headers: requestHeaders 195 | }); 196 | return target.apply(thisArg, argumentArray); 197 | } 198 | }); 199 | 200 | function postXHREvent(phase) { 201 | return () => { 202 | const absoluteURL = xmlHttpRequest.responseURL 203 | ? xmlHttpRequest.responseURL 204 | : toAbsoluteURL(rawRequestURL); 205 | 206 | sendMessage({ 207 | id: crypto.randomUUID(), 208 | source: "xhr", 209 | phase: phase, 210 | url: absoluteURL, 211 | statusCode: xmlHttpRequest.status || null, 212 | mimeType: xmlHttpRequest.getResponseHeader("content-type") || null, 213 | headers: parseRawHeaders(xmlHttpRequest.getAllResponseHeaders()), 214 | body: phase === "response" ? xmlHttpRequest.responseText : null, 215 | errorDescription: phase === "error" ? "Network error" : null 216 | }); 217 | }; 218 | } 219 | 220 | xmlHttpRequest.addEventListener("load", postXHREvent("response")); 221 | xmlHttpRequest.addEventListener("error", postXHREvent("error")); 222 | xmlHttpRequest.addEventListener("abort", postXHREvent("abort")); 223 | 224 | return xmlHttpRequest; 225 | } 226 | 227 | window.XMLHttpRequest = CustomXMLHttpRequest; 228 | })(); 229 | """# 230 | 231 | let userScript = WKUserScript( 232 | source: networkInterceptor, 233 | injectionTime: .atDocumentStart, 234 | forMainFrameOnly: false 235 | ) 236 | userContentController.addUserScript(userScript) 237 | 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+JS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import CorePersistence 6 | import NetworkKit 7 | import Swallow 8 | import WebKit 9 | 10 | extension _BKWebView { 11 | public func makeXMLHttpRequestUsingJavaScript( 12 | _ request: HTTPRequest 13 | ) async throws -> HTTPResponse { 14 | let request = try URLRequest(request) 15 | let url = try request.url.unwrap() 16 | let method = request.httpMethod ?? "GET" 17 | let body = request.httpBody.flatMap({ String(data: $0, encoding: .utf8) }) 18 | let headers = request.allHTTPHeaderFields ?? [:] 19 | 20 | let js = """ 21 | return await new Promise(function (resolve, reject) { 22 | var xhr = new XMLHttpRequest(); 23 | xhr.open("\(method)", "\(request.url!.absoluteString)", true); 24 | \ 25 | \(headers.map { "xhr.setRequestHeader(\"\($0.key)\", \"\($0.value)\");" }.joined(separator: "\n")) 26 | xhr.onload = function() { 27 | var headers = {}; 28 | xhr.getAllResponseHeaders().trim().split(/\\n/).forEach(function (header) { 29 | var parts = header.split(':'); 30 | var key = parts.shift().trim(); 31 | var value = parts.join(':').trim(); 32 | headers[key] = value; 33 | }); 34 | var response = { 35 | status: xhr.status, 36 | statusText: xhr.statusText, 37 | headers: headers, 38 | body: xhr.responseText 39 | }; 40 | resolve(JSON.stringify(response)); 41 | }; 42 | xhr.send(\(body.map({ "\"\($0)\"" }) ?? "")); 43 | }); 44 | """ 45 | 46 | let resultString = try await cast(callAsyncJavaScript(js, contentWorld: .page).unwrap(), to: String.self) 47 | 48 | let result = try cast(JSON(jsonString: resultString).toJSONObject().unwrap(), to: [String: Any].self) 49 | 50 | let resStatus = try cast(result["status"].unwrap(), to: Int.self) 51 | // let resStatusText = try cast(result["statusText"], to: String.self) 52 | let resHeaders = try cast(result["headers"], to: [String: String].self) 53 | let resBody = try result["body"].map({ try cast($0, to: String.self) }) 54 | let resData = try resBody.unwrap().data(using: .utf8).unwrap() 55 | 56 | let httpResponse = HTTPURLResponse( 57 | url: url, 58 | statusCode: resStatus, 59 | httpVersion: nil, 60 | headerFields: resHeaders 61 | ) 62 | 63 | return try HTTPResponse(CachedURLResponse(response: httpResponse.unwrap(), data: resData)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+Logging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Diagnostics 6 | import Swallow 7 | import WebKit 8 | 9 | extension _BKWebView: Logging { 10 | class LoggingScriptMessageHandler: NSObject, WKScriptMessageHandler { 11 | func userContentController( 12 | _ userContentController: WKUserContentController, 13 | didReceive message: WKScriptMessage 14 | ) { 15 | if message.name == "logging" { 16 | print(message.body) 17 | } 18 | } 19 | } 20 | } 21 | 22 | extension WKWebViewConfiguration { 23 | func install(handler: _BKWebView.LoggingScriptMessageHandler) { 24 | let overrideConsole = """ 25 | function log(emoji, type, args) { 26 | window.webkit.messageHandlers.logging.postMessage( 27 | `${emoji} JS ${type}: ${Object.values(args) 28 | .map(v => typeof(v) === "undefined" ? "undefined" : typeof(v) === "object" ? JSON.stringify(v) : v.toString()) 29 | .map(v => v.substring(0, 3000)) // Limit msg to 3000 chars 30 | .join(", ")}` 31 | ) 32 | } 33 | 34 | let originalLog = console.log 35 | let originalWarn = console.warn 36 | let originalError = console.error 37 | let originalDebug = console.debug 38 | 39 | console.log = function() { log("📗", "log", arguments); originalLog.apply(null, arguments) } 40 | console.warn = function() { log("📙", "warning", arguments); originalWarn.apply(null, arguments) } 41 | console.error = function() { log("📕", "error", arguments); originalError.apply(null, arguments) } 42 | console.debug = function() { log("📘", "debug", arguments); originalDebug.apply(null, arguments) } 43 | 44 | window.addEventListener("error", function(e) { 45 | log("💥", "Uncaught", [`${e.message} at ${e.filename}:${e.lineno}:${e.colno}`]) 46 | }) 47 | """ 48 | 49 | userContentController.addUserScript( 50 | WKUserScript( 51 | source: overrideConsole, 52 | injectionTime: .atDocumentStart, 53 | forMainFrameOnly: true 54 | ) 55 | ) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+MutationObserver.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // BrowserKit 4 | // 5 | // Created by Purav Manot on 30/04/25. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | extension _BKWebView { 12 | class MutationObserverScriptMessageHandler: NSObject, WKScriptMessageHandler { 13 | var htmlSourceSubject: PassthroughSubject = .init() 14 | 15 | func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { 16 | guard message.name == "observation" else { return } 17 | 18 | guard let messageBody = message.body as? String else { return } 19 | 20 | htmlSourceSubject.send(messageBody) 21 | } 22 | } 23 | } 24 | extension WKWebViewConfiguration { 25 | func install(handler: _BKWebView.MutationObserverScriptMessageHandler) { 26 | let htmlObservation: String = """ 27 | var observer = new MutationObserver(function(mutations) { 28 | window.webkit.messageHandlers.observation.postMessage(document.documentElement.outerHTML); 29 | }); 30 | 31 | observer.observe(document.documentElement, { childList: true, subtree: true }); 32 | """ 33 | 34 | userContentController.addUserScript(WKUserScript( 35 | source: htmlObservation, 36 | injectionTime: .atDocumentEnd, 37 | forMainFrameOnly: true 38 | )) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+NetworkEvent.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // BrowserKit 4 | // 5 | // Created by Purav Manot on 25/05/25. 6 | // 7 | 8 | import Foundation 9 | public import SwiftUIX 10 | import NetworkKit 11 | import WebKit 12 | 13 | extension _BKWebView { 14 | /// A network request or response that is sent/received within a web view. 15 | public struct NetworkEvent: Codable, Identifiable, Sendable { 16 | public enum Source: String, Codable, Sendable { case fetch, xhr, navigation } 17 | public enum Phase: String, Codable, Sendable { case request, response, error, abort } 18 | 19 | public let id: UUID 20 | public let source: Source 21 | public let phase: Phase 22 | public let url: URL 23 | public let headers: [String: String]? 24 | 25 | // Request-only 26 | public let httpMethod: HTTPMethod? 27 | public let httpBody: Data? 28 | 29 | public let requestBodyPayload: HTTPPayload? 30 | 31 | // Response / error 32 | public let statusCode: Int? 33 | public let mimeType: String? 34 | public let expectedContentLength: Int64? 35 | public let textEncodingName: String? 36 | public let suggestedFilename: String? 37 | public let body: String? 38 | public let errorDescription: String? 39 | 40 | public init( 41 | id: UUID, 42 | source: Source, 43 | phase: Phase, 44 | url: URL, 45 | headers: [String : String]?, 46 | httpMethod: HTTPMethod?, 47 | httpBody: Data?, 48 | requestBodyPayload: HTTPPayload?, 49 | statusCode: Int?, 50 | mimeType: String?, 51 | expectedContentLength: Int64?, 52 | textEncodingName: String?, 53 | suggestedFilename: String?, 54 | body: String?, 55 | errorDescription: String? 56 | ) { 57 | self.id = id 58 | self.source = source 59 | self.phase = phase 60 | self.url = url 61 | self.headers = headers 62 | self.httpMethod = httpMethod 63 | self.httpBody = httpBody 64 | self.requestBodyPayload = requestBodyPayload 65 | self.statusCode = statusCode 66 | self.mimeType = mimeType 67 | self.expectedContentLength = expectedContentLength 68 | self.textEncodingName = textEncodingName 69 | self.suggestedFilename = suggestedFilename 70 | self.body = body 71 | self.errorDescription = errorDescription 72 | } 73 | } 74 | } 75 | 76 | extension _BKWebView.NetworkEvent { 77 | @MainActor 78 | public init(action navigationAction: WKNavigationAction) throws { 79 | let request: URLRequest = navigationAction.request 80 | 81 | self.init( 82 | id: UUID(), 83 | source: .navigation, 84 | phase: .request, 85 | url: try request.url.forceUnwrap(), 86 | headers: request.allHTTPHeaderFields, 87 | httpMethod: request.httpMethod.flatMap { HTTPMethod(rawValue: $0) }, 88 | httpBody: request.httpBody, 89 | requestBodyPayload: HTTPPayload(request: request), 90 | statusCode: nil, 91 | mimeType: request.mimeType, 92 | expectedContentLength: nil, 93 | textEncodingName: nil, 94 | suggestedFilename: nil, 95 | body: nil, 96 | errorDescription: nil 97 | ) 98 | } 99 | 100 | // MARK: WKNavigationResponse → response event 101 | @MainActor 102 | public init(response navigationResponse: WKNavigationResponse) throws { 103 | let urlResponse = navigationResponse.response 104 | let httpURLResponse = urlResponse as? HTTPURLResponse 105 | 106 | id = UUID() 107 | source = .navigation 108 | phase = .response 109 | url = try urlResponse.url.unwrap() 110 | headers = httpURLResponse?.allHeaderFields as? [String: String] 111 | 112 | httpMethod = nil 113 | httpBody = nil 114 | requestBodyPayload = nil 115 | 116 | statusCode = httpURLResponse?.statusCode 117 | mimeType = urlResponse.mimeType 118 | expectedContentLength = urlResponse.expectedContentLength 119 | textEncodingName = urlResponse.textEncodingName 120 | suggestedFilename = urlResponse.suggestedFilename 121 | body = nil 122 | errorDescription = nil 123 | } 124 | } 125 | 126 | extension _BKWebView.NetworkEvent { 127 | public enum HTTPPayload: Codable, Equatable, Sendable { 128 | case text(String) 129 | case form([String: String]) 130 | case json(Data) 131 | case xml(Data) 132 | case html(String) 133 | case csv(String) 134 | case image(_AnyImage) 135 | case data(Data) 136 | case custom(mime: String, data: Data) 137 | 138 | public init?(request: URLRequest) { 139 | guard let body = request.httpBody else { 140 | return nil 141 | } 142 | 143 | let mime: String = request 144 | .value(forHTTPHeaderField: "Content-Type")? 145 | .split(separator: ";", maxSplits: 1) 146 | .first? 147 | .trimmingCharacters(in: .whitespacesAndNewlines) 148 | .lowercased() ?? "application/octet-stream" 149 | 150 | switch mime { 151 | case HTTPMediaType.plainText.rawValue: 152 | guard let string = String(data: body, encoding: .utf8) else { return nil } 153 | self = .text(string) 154 | 155 | case HTTPMediaType.form.rawValue: 156 | self = .form(Self.parseForm(body)) 157 | 158 | case HTTPMediaType.json.rawValue: 159 | self = .json(body) 160 | 161 | case HTTPMediaType.xml.rawValue: 162 | self = .xml(body) 163 | 164 | case HTTPMediaType.html.rawValue: 165 | guard let str = String(data: body, encoding: .utf8) else { return nil } 166 | self = .html(str) 167 | 168 | case HTTPMediaType.csv.rawValue: 169 | guard let str = String(data: body, encoding: .utf8) else { return nil } 170 | self = .csv(str) 171 | 172 | case HTTPMediaType.octetStream.rawValue: 173 | self = .data(body) 174 | 175 | // --- images ------------------------------------------------------- 176 | case _ where mime.hasPrefix("image/"): 177 | if let image: _AnyImage = _AnyImage(data: body) { 178 | self = .image(image) 179 | } else { 180 | self = .custom(mime: mime, data: body) 181 | } 182 | 183 | default: 184 | self = .custom(mime: mime, data: body) 185 | } 186 | } 187 | 188 | static func parseForm(_ data: Data) -> [String: String] { 189 | guard let string = String(data: data, encoding: .utf8), !string.isEmpty else { return [:] } 190 | return string.split(separator: "&").reduce(into: [:]) { dict, pair in 191 | let parts = pair.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) 192 | guard parts.count >= 1 else { return } 193 | 194 | let key = parts[0].replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? String(parts[0]) 195 | let value = parts.count > 1 ? (parts[1].replacingOccurrences(of: "+", with: " ").removingPercentEncoding ?? String(parts[1])) : "" 196 | dict[key] = value 197 | } 198 | } 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+NetworkMessagePattern.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // BrowserKit 4 | // 5 | // Created by Purav Manot on 29/04/25. 6 | // 7 | 8 | import Foundation 9 | import Swallow 10 | 11 | extension _BKWebView { 12 | public struct NetworkMessagePattern: Identifiable { 13 | enum Payload { 14 | case custom((NetworkEvent) throws -> Bool) 15 | } 16 | 17 | let identifier: _AutoIncrementingIdentifier = _AutoIncrementingIdentifier() 18 | let payload: Payload 19 | 20 | public var id: AnyHashable { 21 | identifier 22 | } 23 | 24 | private init(payload: Payload) { 25 | self.payload = payload 26 | } 27 | 28 | public init(predicate: @escaping (NetworkEvent) throws -> Bool) { 29 | self.init(payload: .custom(predicate)) 30 | } 31 | 32 | public func matches(_ message: NetworkEvent) throws -> Bool { 33 | switch payload { 34 | case .custom(let predicate): 35 | return try predicate(message) 36 | } 37 | } 38 | 39 | public func callAsFunction(_ message: NetworkEvent) throws -> Bool { 40 | try matches(message) 41 | } 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+NetworkMessagePublisher.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // BrowserKit 4 | // 5 | // Created by Purav Manot on 29/04/25. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | extension _BKWebView { 12 | public func networkMessages( 13 | matching pattern: NetworkMessagePattern 14 | ) -> AnyPublisher { 15 | NetworkMessagePublisher(messageHandler: self.networkScriptMessageHandler, pattern: pattern) 16 | .share() 17 | .eraseToAnyPublisher() 18 | } 19 | } 20 | 21 | 22 | extension _BKWebView { 23 | fileprivate struct NetworkMessagePublisher: Publisher { 24 | typealias Output = NetworkEvent 25 | typealias Failure = Never 26 | 27 | private weak var messageHandler: _BKWebView.NetworkScriptMessageHandler? 28 | fileprivate let pattern: NetworkMessagePattern 29 | 30 | func receive(subscriber: S) where S.Input == Output, S.Failure == Failure { 31 | let subscription = NavigationEventSubscription( 32 | downstream: subscriber, 33 | messageHandler: messageHandler, 34 | predicate: pattern 35 | ) 36 | 37 | subscriber.receive(subscription: subscription) 38 | } 39 | 40 | init( 41 | messageHandler: _BKWebView.NetworkScriptMessageHandler? = nil, 42 | pattern: NetworkMessagePattern 43 | ) { 44 | self.messageHandler = messageHandler 45 | self.pattern = pattern 46 | } 47 | } 48 | 49 | private final class NavigationEventSubscription: NSObject, Subscription where S.Input == NetworkEvent, S.Failure == Never { 50 | private var id = UUID() 51 | private var downstream: S? 52 | @MainActor 53 | private weak var messageHandler: _BKWebView.NetworkScriptMessageHandler? 54 | private let predicate: NetworkMessagePattern 55 | private var demand: Subscribers.Demand = .none 56 | 57 | private var observing: Bool = false 58 | 59 | init(downstream: S, 60 | messageHandler: _BKWebView.NetworkScriptMessageHandler?, 61 | predicate: NetworkMessagePattern) 62 | { 63 | self.downstream = downstream 64 | self.messageHandler = messageHandler 65 | self.predicate = predicate 66 | super.init() 67 | 68 | } 69 | 70 | func request(_ newDemand: Subscribers.Demand) { 71 | demand += newDemand 72 | 73 | guard !observing else { return } 74 | 75 | observing = true 76 | 77 | let handler: NetworkMessageHandler = NetworkMessageHandler(id: id, predicate: predicate) { [weak self] message in 78 | guard let self = self else { return } 79 | 80 | guard self.demand > 0 || self.demand == .unlimited else { return } 81 | let additional = downstream?.receive(message) ?? .none 82 | 83 | if demand != .unlimited { demand -= 1 } 84 | demand += additional 85 | } 86 | 87 | Task.detached { @MainActor [weak self] in 88 | self?.messageHandler?.handlers.insert(handler) 89 | } 90 | } 91 | 92 | func cancel() { 93 | Task.detached { @MainActor [weak self] in 94 | guard let id = self?.id else { return } 95 | self?.messageHandler?.handlers.remove(elementIdentifiedBy: id) 96 | } 97 | 98 | downstream = nil 99 | } 100 | } 101 | } 102 | 103 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+Utilities.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Swallow 6 | 7 | extension _BKWebView { 8 | public func currentHTML() async throws -> String { 9 | try await load() 10 | 11 | let result = try await evaluateJavaScript("document.documentElement.outerHTML.toString()") 12 | 13 | return try cast(result, to: String.self) 14 | } 15 | 16 | public func type(_ text: String, selector: String, shouldSubmit: Bool = false) async throws { 17 | _ = try await self.callAsyncJavaScript(typeTextJS, arguments: ["selector": selector, "text": text, "shouldSubmit": shouldSubmit], contentWorld: .defaultClient) 18 | } 19 | 20 | public func waitForSelector(selector: String, timeout: TimeInterval) async throws -> Bool { 21 | let functionBody = """ 22 | return await new Promise((resolve, reject) => { 23 | if (document.querySelector(selector)) { 24 | console.log('Found selector immediately'); 25 | return resolve(true); 26 | } 27 | 28 | const timeoutId = setTimeout(() => { 29 | observer.disconnect(); 30 | resolve(false) 31 | }, timeout * 1000); 32 | 33 | const observer = new MutationObserver(() => { 34 | if (document.querySelector(selector)) { 35 | clearTimeout(timeoutId); 36 | observer.disconnect(); 37 | console.log('Found selector after waiting'); 38 | resolve(true); 39 | } 40 | }); 41 | 42 | observer.observe(document.body, { 43 | childList: true, 44 | subtree: true, 45 | attributes: true 46 | }); 47 | }); 48 | """ 49 | 50 | let response = try await self.callAsyncJavaScript(functionBody, arguments: ["selector": selector, "timeout": timeout], contentWorld: .defaultClient) 51 | 52 | guard let value = response as? Bool else { 53 | throw DecodingError.typeMismatch(Bool.self, DecodingError.Context(codingPath: [], debugDescription: "Expected Bool")) 54 | } 55 | 56 | return value 57 | } 58 | 59 | public func click(selector: String) async throws { 60 | let functionBody = """ 61 | document.querySelector(selector).click(); 62 | """ 63 | _ = try await self.callAsyncJavaScript(functionBody, arguments: ["selector": selector], contentWorld: .defaultClient) 64 | } 65 | } 66 | 67 | fileprivate let typeTextJS = """ 68 | function type(selector, text, shouldSubmit = false) { 69 | const element = document.querySelector(selector); 70 | 71 | if (!element) { 72 | console.warn('Element not found'); 73 | return; 74 | } 75 | 76 | element.focus(); 77 | 78 | setNativeValue(element, text); 79 | if ('defaultValue' in element) { 80 | element.defaultValue = text; 81 | } 82 | 83 | ['input', 'change'].forEach(eventType => { 84 | const event = new Event(eventType, { bubbles: true }); 85 | element.dispatchEvent(event); 86 | }); 87 | 88 | ['keydown', 'keypress', 'keyup'].forEach(eventType => { 89 | const keyboardEvent = new KeyboardEvent(eventType, { 90 | key: 'Enter', 91 | code: 'Enter', 92 | keyCode: 13, 93 | which: 13, 94 | bubbles: true, 95 | }); 96 | element.dispatchEvent(keyboardEvent); 97 | }); 98 | 99 | if (shouldSubmit && element.form) { 100 | setTimeout(() => { 101 | try { 102 | element.form.requestSubmit(); 103 | } catch (error) { 104 | console.error('Form submission failed', error); 105 | } 106 | }, 500); 107 | } 108 | } 109 | 110 | function setNativeValue(element, value) { 111 | if ('value' in element) { 112 | let lastValue = element.value; 113 | element.value = value; 114 | 115 | let inputEvent = new Event("input", { target: element, bubbles: true }); 116 | // React 15 117 | inputEvent.simulated = true; 118 | // React 16 119 | let tracker = element._valueTracker; 120 | if (tracker) { 121 | tracker.setValue(lastValue); 122 | } 123 | element.dispatchEvent(inputEvent); 124 | 125 | element.dispatchEvent(new Event("change", { target: element, bubbles: true })); 126 | } 127 | 128 | let lastTextContent = element.textContent; 129 | element.textContent = value; 130 | 131 | if (lastTextContent !== value) { 132 | let mutationEvent = new Event("DOMSubtreeModified", { target: element, bubbles: true }); 133 | mutationEvent.simulated = true; 134 | element.dispatchEvent(mutationEvent); 135 | 136 | if (!('value' in element)) { 137 | let textInputEvent = new Event("input", { target: element, bubbles: true }); 138 | textInputEvent.simulated = true; 139 | element.dispatchEvent(textInputEvent); 140 | } 141 | } 142 | } 143 | 144 | return type(selector, text, shouldSubmit) 145 | """ 146 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView+WKNavigationDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | internal import Merge 6 | import Foundation 7 | import Swallow 8 | 9 | extension _BKWebView: WKNavigationDelegate { 10 | public func webView( 11 | _ webView: WKWebView, 12 | decidePolicyFor navigationAction: WKNavigationAction 13 | ) async -> WKNavigationActionPolicy { 14 | self.networkScriptMessageHandler.handlers.forEach { handler in 15 | #try(.optimistic) { 16 | try handler(.init(action: navigationAction)) 17 | } 18 | } 19 | 20 | return .allow 21 | } 22 | 23 | public func webView( 24 | _ webView: WKWebView, 25 | decidePolicyFor navigationAction: WKNavigationAction, 26 | preferences: WKWebpagePreferences 27 | ) async -> (WKNavigationActionPolicy, WKWebpagePreferences) { 28 | let policy = await self.webView(webView, decidePolicyFor: navigationAction) 29 | 30 | return (policy, preferences) 31 | } 32 | 33 | public func webView( 34 | _ webView: WKWebView, 35 | didStartProvisionalNavigation navigation: WKNavigation? 36 | ) { 37 | Task.detached { 38 | try await self.task(for: navigation) 39 | } 40 | } 41 | 42 | public func webView( 43 | _ webView: WKWebView, 44 | decidePolicyFor navigationResponse: WKNavigationResponse 45 | ) async -> WKNavigationResponsePolicy { 46 | self.networkScriptMessageHandler.handlers.forEach { handler in 47 | #try(.optimistic) { 48 | try handler(.init(response: navigationResponse)) 49 | } 50 | } 51 | 52 | await self._navigationState.setLastResponse(navigationResponse) 53 | 54 | guard 55 | let response = navigationResponse.response as? HTTPURLResponse, 56 | let allHeaderFields = response.allHeaderFields as? [String: String], 57 | let url = response.url 58 | else { 59 | return .allow 60 | } 61 | 62 | Task { 63 | await updateCookies(HTTPCookie.cookies(withResponseHeaderFields: allHeaderFields, for: url)) 64 | } 65 | 66 | return .allow 67 | } 68 | 69 | public func webView( 70 | _ webView: WKWebView, 71 | didReceiveServerRedirectForProvisionalNavigation navigation: WKNavigation? 72 | ) { 73 | 74 | } 75 | 76 | public func webView( 77 | _ webView: WKWebView, 78 | didCommit navigation: WKNavigation? 79 | ) { 80 | if cookieSyncStrategy == .syncEnabled { 81 | Task.detached(priority: .background) { 82 | await self.syncCookiesToSharedHTTPCookieStorage(webView: webView) 83 | } 84 | } 85 | } 86 | 87 | public func webView( 88 | _ webView: WKWebView, 89 | didFinish navigation: WKNavigation? 90 | ) { 91 | if url?.absoluteString != nil { 92 | coordinator?.state.isLoading = false 93 | } 94 | 95 | Task.detached { 96 | await self._navigationState.resolve(navigation, with: .success(())) 97 | } 98 | } 99 | 100 | public func webView( 101 | _ webView: WKWebView, 102 | didFail navigation: WKNavigation?, 103 | withError error: Error 104 | ) { 105 | Task.detached { 106 | await self._navigationState.resolve(navigation, with: .error(error)) 107 | } 108 | } 109 | 110 | public func webView( 111 | _ webView: WKWebView, 112 | didReceive challenge: URLAuthenticationChallenge, 113 | completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void 114 | ) { 115 | let challenge = _UncheckedSendable(challenge) 116 | 117 | Task.detached { 118 | let challenge = challenge.wrappedValue 119 | var disposition: URLSession.AuthChallengeDisposition = .performDefaultHandling 120 | var credential: URLCredential? 121 | 122 | if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust { 123 | if let serverTrust = challenge.protectionSpace.serverTrust { 124 | credential = URLCredential(trust: serverTrust) 125 | disposition = .useCredential 126 | } 127 | } else { 128 | disposition = .cancelAuthenticationChallenge 129 | } 130 | 131 | completionHandler(disposition, credential) 132 | } 133 | } 134 | 135 | public func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { 136 | 137 | } 138 | } 139 | 140 | 141 | extension Data { 142 | public func prettyPrintedJSON() -> String? { 143 | do { 144 | let json = try JSONSerialization.jsonObject(with: self) 145 | let prettyData = try JSONSerialization.data( 146 | withJSONObject: json, 147 | options: [.prettyPrinted, .sortedKeys] 148 | ) 149 | return String(data: prettyData, encoding: .utf8) 150 | } catch { 151 | return nil 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Sources/Intramodular/_BKWebView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import Combine 6 | internal import Merge 7 | import Swallow 8 | import WebKit 9 | 10 | @MainActor 11 | open class _BKWebView: WKWebView { 12 | weak var coordinator: WebView._BKWebViewRepresentable.Coordinator? 13 | 14 | var _navigationState = _NavigationState() 15 | 16 | /// Used when `_BKWebView` is loading scripts with a `WKUIDelegate` hack.. 17 | private var scriptContinuations: [String: CheckedContinuation] = [:] 18 | private var userScriptContinuations: [String: CheckedContinuation] = [:] 19 | 20 | // FIXME: (@vmanot) - temporarily added to turn off syncinc 21 | internal var cookieSyncStrategy: CookieSyncStrategy = .syncDisabled 22 | public var navigationEvents: AnyAsyncSequence> { 23 | get async { 24 | AnyAsyncSequence(await _navigationState.navigationEventsSubject.values) 25 | } 26 | } 27 | 28 | private let loggingScriptMessageHandler = LoggingScriptMessageHandler() 29 | private let mutationObserverScriptMessageHandler = MutationObserverScriptMessageHandler() 30 | let networkScriptMessageHandler = NetworkScriptMessageHandler() 31 | 32 | @MainActor 33 | init() { 34 | HTTPCookieStorage.shared.cookieAcceptPolicy = .always 35 | 36 | let configuration = WKWebViewConfiguration() 37 | 38 | configuration.processPool = Self.ProcessPool.shared 39 | 40 | configuration.install(handler: loggingScriptMessageHandler) 41 | configuration.install(handler: mutationObserverScriptMessageHandler) 42 | configuration.install(handler: networkScriptMessageHandler) 43 | 44 | configuration.userContentController.removeAllScriptMessageHandlers() 45 | configuration.userContentController.add(networkScriptMessageHandler, name: "network") 46 | configuration.userContentController.add(loggingScriptMessageHandler, name: "logging") 47 | configuration.userContentController.add(mutationObserverScriptMessageHandler, name: "observation") 48 | 49 | assert(configuration.processPool == Self.ProcessPool.shared) 50 | 51 | super.init(frame: .zero, configuration: configuration) 52 | 53 | self.navigationDelegate = self 54 | self.uiDelegate = self 55 | 56 | configuration.websiteDataStore.httpCookieStore.add(self) 57 | } 58 | 59 | required public init?(coder: NSCoder) { 60 | fatalError("init(coder:) has not been implemented, init(frame:configurationBlock:)") 61 | } 62 | 63 | @MainActor 64 | @discardableResult 65 | open override func load(_ request: URLRequest) -> WKNavigation? { 66 | if let url = request.url { 67 | configuration.userContentController = userContentController(for: url) 68 | } 69 | 70 | let navigation = super.load(request) 71 | 72 | Task.detached { 73 | try await self.task(for: navigation) 74 | } 75 | 76 | return navigation 77 | } 78 | 79 | open override func goForward() -> WKNavigation? { 80 | let navigation = super.goForward() 81 | 82 | Task.detached { 83 | try await self.task(for: navigation) 84 | } 85 | 86 | return navigation 87 | } 88 | 89 | open override func goBack() -> WKNavigation? { 90 | let navigation = super.goBack() 91 | 92 | Task.detached { 93 | try await self.task(for: navigation) 94 | } 95 | 96 | return navigation 97 | } 98 | 99 | @MainActor 100 | open override func reload() -> WKNavigation? { 101 | let navigation = super.reload() 102 | 103 | Task.detached { 104 | try await self.task(for: navigation) 105 | } 106 | 107 | return navigation 108 | } 109 | 110 | @MainActor 111 | open override func reloadFromOrigin() -> WKNavigation? { 112 | let navigation = super.reloadFromOrigin() 113 | 114 | Task.detached { 115 | try await self.task(for: navigation) 116 | } 117 | 118 | return navigation 119 | } 120 | 121 | // FIXME: (@vatsal) - Figure out how to avoid priority inversion 122 | func syncCookiesToSharedHTTPCookieStorage(webView: WKWebView) async { 123 | guard let url = url, let host = url.host else { 124 | return 125 | } 126 | 127 | let sharedStoredCookies = HTTPCookieStorage.shared.cookies(for: url) 128 | 129 | let cookies = await configuration.websiteDataStore.httpCookieStore.allCookies().filter { 130 | host.range(of: $0.domain) != nil || $0.domain.range(of: host) != nil 131 | } 132 | 133 | for cookie in cookies { 134 | if let sharedStoredCookies { 135 | sharedStoredCookies 136 | .filter({ $0.name == cookie.name }) 137 | .forEach { 138 | HTTPCookieStorage.shared.deleteCookie($0) // priority inversion here 139 | } 140 | } 141 | 142 | HTTPCookieStorage.shared.setCookie(cookie) 143 | } 144 | } 145 | } 146 | 147 | extension _BKWebView { 148 | func task( 149 | for navigation: WKNavigation? 150 | ) async throws -> Task { 151 | return try await _navigationState.task(for: navigation.unwrap()) 152 | } 153 | 154 | func asyncResult( 155 | for navigation: WKNavigation? 156 | ) async throws -> WKNavigation.Success { 157 | try await task(for: navigation).value 158 | } 159 | } 160 | 161 | extension _BKWebView: WKHTTPCookieStoreObserver { 162 | public func cookiesDidChange(in cookieStore: WKHTTPCookieStore) { 163 | Task { 164 | coordinator?.cookies = await cookieStore.allCookies() 165 | } 166 | } 167 | } 168 | 169 | extension _BKWebView { 170 | @MainActor 171 | public func loadScript( 172 | _ script: String 173 | ) async throws { 174 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 175 | let id = UUID().uuidString 176 | 177 | Task { @MainActor in 178 | self.scriptContinuations[id] = continuation 179 | 180 | let html = String( 181 | """ 182 | 183 | 184 | 185 | 186 | """ 187 | ) 188 | 189 | self.loadHTMLString(html, baseURL: nil) 190 | } 191 | } 192 | } 193 | } 194 | 195 | extension _BKWebView: WKUIDelegate { 196 | public func webView( 197 | _ webView: WKWebView, 198 | runJavaScriptAlertPanelWithMessage message: String, 199 | initiatedByFrame frame: WKFrameInfo 200 | ) async { 201 | if let continuation = self.scriptContinuations[message] { 202 | continuation.resume(returning: ()) 203 | } 204 | } 205 | } 206 | 207 | // MARK: - API 208 | 209 | extension _BKWebView { 210 | @discardableResult 211 | @MainActor 212 | public func load() async throws -> WKNavigation.Success? { 213 | try await _navigationState.lastTask?.value 214 | } 215 | 216 | @discardableResult 217 | @MainActor 218 | public func load(_ urlRequest: URLRequest) async throws -> WKNavigation.Success { 219 | try await asyncResult(for: self.load(urlRequest)) 220 | } 221 | 222 | @MainActor 223 | @discardableResult 224 | public func load(_ url: URL) async throws -> WKNavigation.Success { 225 | try await load(URLRequest(url: url)) 226 | } 227 | 228 | @discardableResult 229 | @MainActor 230 | public func goForward() async throws -> WKNavigation.Success { 231 | try await asyncResult(for: self.goForward()) 232 | } 233 | 234 | @discardableResult 235 | @MainActor 236 | public func goBack() async throws -> WKNavigation.Success { 237 | try await asyncResult(for: self.goBack()) 238 | } 239 | 240 | public func waitForURLChange(timeout: TimeInterval = 10) async throws { 241 | return try await Task(timeout: timeout) { @MainActor in 242 | var observation: NSKeyValueObservation? 243 | await withCheckedContinuation { continuation in 244 | observation = self.observe(\.url, options: [.prior]) { _, change in 245 | continuation.resume(returning: ().self) 246 | observation?.invalidate() 247 | } 248 | } 249 | }.value 250 | } 251 | 252 | } 253 | 254 | // MARK: - Auxiliary 255 | 256 | extension _BKWebView { 257 | public func htmlPublisher() -> AnyPublisher { 258 | self.mutationObserverScriptMessageHandler.htmlSourceSubject.eraseToAnyPublisher() 259 | } 260 | } 261 | 262 | extension _BKWebView { 263 | actor _NavigationState { 264 | enum Resolution: Sendable { 265 | case taskWasCancelled 266 | case wasOverridden(by: WKNavigation?) 267 | case result(Result) 268 | } 269 | 270 | enum _Error: Swift.Error { 271 | case overriden 272 | case navigationMismatch 273 | case foundExistingResolution(Resolution, for: WKNavigation) 274 | } 275 | 276 | let navigationEventsSubject = PassthroughSubject, Never>() 277 | 278 | private var last: WKNavigation? 279 | private var lastResponse: WKNavigationResponse? 280 | private var continuations: [WKNavigation: CheckedContinuation] = [:] 281 | private var tasks: [WKNavigation: Task] = [:] 282 | private var resolutions: [WKNavigation: Resolution] = [:] 283 | 284 | var lastTask: Task? { 285 | get { 286 | guard let last, let lastTask = tasks[last] else { 287 | return nil 288 | } 289 | 290 | return lastTask 291 | } 292 | } 293 | 294 | func task(for navigation: WKNavigation) -> Task { 295 | if let task = tasks[navigation] { 296 | return task 297 | } else { 298 | return createTask(for: navigation) 299 | } 300 | } 301 | 302 | private func createTask(for navigation: WKNavigation) -> Task { 303 | let task = Task(priority: .userInitiated) { 304 | try await withTaskCancellationHandler { 305 | try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in 306 | Task.detached { 307 | await self.begin(navigation, with: continuation) 308 | } 309 | } 310 | } onCancel: { 311 | Task { 312 | await self.resolve(navigation, with: .canceled) 313 | } 314 | } 315 | 316 | 317 | } 318 | 319 | tasks[navigation] = task 320 | 321 | return task 322 | } 323 | 324 | func begin( 325 | _ navigation: WKNavigation?, 326 | with continuation: CheckedContinuation 327 | ) { 328 | Task { 329 | if let last = last { 330 | resolutions[last] = .wasOverridden(by: navigation) 331 | continuations[last]?.resume(throwing: _Error.overriden) 332 | continuations[last] = nil 333 | 334 | tasks[last]?.cancel() 335 | } 336 | 337 | self.last = navigation 338 | self.continuations[navigation] = continuation 339 | } 340 | } 341 | 342 | func setLastResponse(_ response: WKNavigationResponse?) { 343 | self.lastResponse = response 344 | } 345 | 346 | func resolve( 347 | _ navigation: WKNavigation?, 348 | with result: TaskResult 349 | ) async { 350 | 351 | guard let navigation else { 352 | return 353 | } 354 | 355 | if let existingResolution = resolutions[navigation] { 356 | switch existingResolution { 357 | case .wasOverridden, .taskWasCancelled: 358 | return 359 | default: 360 | let error = _Error.foundExistingResolution(existingResolution, for: navigation) 361 | 362 | return assertionFailure(error) 363 | } 364 | } 365 | 366 | guard let continuation = continuations[navigation] else { 367 | return 368 | } 369 | 370 | let navigationResult: Result 371 | 372 | assert(tasks[navigation] != nil) 373 | 374 | do { 375 | if let last = last, last != navigation { 376 | navigationResult = .failure(_Error.navigationMismatch) 377 | } else { 378 | _ = try result.get() 379 | 380 | navigationResult = await .success(.init(urlResponse: try lastResponse.unwrap().response)) 381 | } 382 | } catch { 383 | navigationResult = .failure(error) 384 | } 385 | 386 | continuation.resume(with: navigationResult) 387 | 388 | navigationEventsSubject.send(navigationResult) 389 | 390 | last = nil 391 | lastResponse = nil 392 | continuations[navigation] = nil 393 | tasks[navigation] = nil 394 | 395 | switch result { 396 | case .canceled: 397 | resolutions[navigation] = .taskWasCancelled 398 | default: 399 | resolutions[navigation] = .result(navigationResult) 400 | } 401 | } 402 | } 403 | 404 | fileprivate final class ProcessPool: WKProcessPool { 405 | static let shared = ProcessPool() 406 | } 407 | } 408 | 409 | // MARK: - Helpers 410 | 411 | extension HTTPCookie { 412 | private static let dateFormatter: DateFormatter = { 413 | let dateFormatter = DateFormatter() 414 | 415 | dateFormatter.locale = Locale(identifier: "en_US") 416 | dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) 417 | dateFormatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" 418 | 419 | return dateFormatter 420 | }() 421 | 422 | var javascriptString: String { 423 | if var properties = properties { 424 | properties.removeValue(forKey: .name) 425 | properties.removeValue(forKey: .value) 426 | 427 | return properties 428 | .reduce(into: ["\(name)=\(value)"]) { result, property in 429 | result.append("\(property.key.rawValue)=\(property.value)") 430 | } 431 | .joined(separator: "; ") 432 | } 433 | 434 | var script = [ 435 | "\(name)=\(value)", 436 | "domain=\(domain)", 437 | "path=\(path)" 438 | ] 439 | 440 | if isSecure { 441 | script.append("secure=true") 442 | } 443 | 444 | if let expiresDate = expiresDate { 445 | script.append("expires=\(HTTPCookie.dateFormatter.string(from: expiresDate))") 446 | } 447 | 448 | return script.joined(separator: "; ") 449 | } 450 | } 451 | 452 | extension WKHTTPCookieStore { 453 | public func deleteAllCookies() async { 454 | for cookie in await allCookies() { 455 | await deleteCookie(cookie) 456 | } 457 | } 458 | } 459 | 460 | extension _BKWebView { 461 | enum CookieSyncStrategy { 462 | case syncEnabled 463 | case syncDisabled 464 | } 465 | } 466 | -------------------------------------------------------------------------------- /Sources/Resources/showdown.min.js: -------------------------------------------------------------------------------- 1 | /*! showdown v 2.1.0 - 21-04-2022 */ 2 | !function(){function a(e){"use strict";var r={omitExtraWLInCodeBlocks:{defaultValue:!1,describe:"Omit the default extra whiteline added to code blocks",type:"boolean"},noHeaderId:{defaultValue:!1,describe:"Turn on/off generated header id",type:"boolean"},prefixHeaderId:{defaultValue:!1,describe:"Add a prefix to the generated header ids. Passing a string will prefix that string to the header id. Setting to true will add a generic 'section-' prefix",type:"string"},rawPrefixHeaderId:{defaultValue:!1,describe:'Setting this option to true will prevent showdown from modifying the prefix. This might result in malformed IDs (if, for instance, the " char is used in the prefix)',type:"boolean"},ghCompatibleHeaderId:{defaultValue:!1,describe:"Generate header ids compatible with github style (spaces are replaced with dashes, a bunch of non alphanumeric chars are removed)",type:"boolean"},rawHeaderId:{defaultValue:!1,describe:"Remove only spaces, ' and \" from generated header ids (including prefixes), replacing them with dashes (-). WARNING: This might result in malformed ids",type:"boolean"},headerLevelStart:{defaultValue:!1,describe:"The header blocks level start",type:"integer"},parseImgDimensions:{defaultValue:!1,describe:"Turn on/off image dimension parsing",type:"boolean"},simplifiedAutoLink:{defaultValue:!1,describe:"Turn on/off GFM autolink style",type:"boolean"},excludeTrailingPunctuationFromURLs:{defaultValue:!1,describe:"Excludes trailing punctuation from links generated with autoLinking",type:"boolean"},literalMidWordUnderscores:{defaultValue:!1,describe:"Parse midword underscores as literal underscores",type:"boolean"},literalMidWordAsterisks:{defaultValue:!1,describe:"Parse midword asterisks as literal asterisks",type:"boolean"},strikethrough:{defaultValue:!1,describe:"Turn on/off strikethrough support",type:"boolean"},tables:{defaultValue:!1,describe:"Turn on/off tables support",type:"boolean"},tablesHeaderId:{defaultValue:!1,describe:"Add an id to table headers",type:"boolean"},ghCodeBlocks:{defaultValue:!0,describe:"Turn on/off GFM fenced code blocks support",type:"boolean"},tasklists:{defaultValue:!1,describe:"Turn on/off GFM tasklist support",type:"boolean"},smoothLivePreview:{defaultValue:!1,describe:"Prevents weird effects in live previews due to incomplete input",type:"boolean"},smartIndentationFix:{defaultValue:!1,describe:"Tries to smartly fix indentation in es6 strings",type:"boolean"},disableForced4SpacesIndentedSublists:{defaultValue:!1,describe:"Disables the requirement of indenting nested sublists by 4 spaces",type:"boolean"},simpleLineBreaks:{defaultValue:!1,describe:"Parses simple line breaks as
(GFM Style)",type:"boolean"},requireSpaceBeforeHeadingText:{defaultValue:!1,describe:"Makes adding a space between `#` and the header text mandatory (GFM Style)",type:"boolean"},ghMentions:{defaultValue:!1,describe:"Enables github @mentions",type:"boolean"},ghMentionsLink:{defaultValue:"https://github.com/{u}",describe:"Changes the link generated by @mentions. Only applies if ghMentions option is enabled.",type:"string"},encodeEmails:{defaultValue:!0,describe:"Encode e-mail addresses through the use of Character Entities, transforming ASCII e-mail addresses into its equivalent decimal entities",type:"boolean"},openLinksInNewWindow:{defaultValue:!1,describe:"Open all links in new windows",type:"boolean"},backslashEscapesHTMLTags:{defaultValue:!1,describe:"Support for HTML Tag escaping. ex:
foo
",type:"boolean"},emoji:{defaultValue:!1,describe:"Enable emoji support. Ex: `this is a :smile: emoji`",type:"boolean"},underline:{defaultValue:!1,describe:"Enable support for underline. Syntax is double or triple underscores: `__underline word__`. With this option enabled, underscores no longer parses into `` and ``",type:"boolean"},ellipsis:{defaultValue:!0,describe:"Replaces three dots with the ellipsis unicode character",type:"boolean"},completeHTMLDocument:{defaultValue:!1,describe:"Outputs a complete html document, including ``, `` and `` tags",type:"boolean"},metadata:{defaultValue:!1,describe:"Enable support for document metadata (defined at the top of the document between `«««` and `»»»` or between `---` and `---`).",type:"boolean"},splitAdjacentBlockquotes:{defaultValue:!1,describe:"Split adjacent blockquote blocks",type:"boolean"}};if(!1===e)return JSON.parse(JSON.stringify(r));var t,a={};for(t in r)r.hasOwnProperty(t)&&(a[t]=r[t].defaultValue);return a}var x={},t={},d={},p=a(!0),h="vanilla",_={github:{omitExtraWLInCodeBlocks:!0,simplifiedAutoLink:!0,excludeTrailingPunctuationFromURLs:!0,literalMidWordUnderscores:!0,strikethrough:!0,tables:!0,tablesHeaderId:!0,ghCodeBlocks:!0,tasklists:!0,disableForced4SpacesIndentedSublists:!0,simpleLineBreaks:!0,requireSpaceBeforeHeadingText:!0,ghCompatibleHeaderId:!0,ghMentions:!0,backslashEscapesHTMLTags:!0,emoji:!0,splitAdjacentBlockquotes:!0},original:{noHeaderId:!0,ghCodeBlocks:!1},ghost:{omitExtraWLInCodeBlocks:!0,parseImgDimensions:!0,simplifiedAutoLink:!0,excludeTrailingPunctuationFromURLs:!0,literalMidWordUnderscores:!0,strikethrough:!0,tables:!0,tablesHeaderId:!0,ghCodeBlocks:!0,tasklists:!0,smoothLivePreview:!0,simpleLineBreaks:!0,requireSpaceBeforeHeadingText:!0,ghMentions:!1,encodeEmails:!0},vanilla:a(!0),allOn:function(){"use strict";var e,r=a(!0),t={};for(e in r)r.hasOwnProperty(e)&&(t[e]=!0);return t}()};function g(e,r){"use strict";var t=r?"Error in "+r+" extension->":"Error in unnamed extension",a={valid:!0,error:""};x.helper.isArray(e)||(e=[e]);for(var n=0;n").replace(/&/g,"&")};function u(e,r,t,a){"use strict";var n,s,o,i=-1<(a=a||"").indexOf("g"),l=new RegExp(r+"|"+t,"g"+a.replace(/g/g,"")),c=new RegExp(r,a.replace(/g/g,"")),u=[];do{for(n=0;p=l.exec(e);)if(c.test(p[0]))n++||(o=(s=l.lastIndex)-p[0].length);else if(n&&!--n){var d=p.index+p[0].length,p={left:{start:o,end:s},match:{start:s,end:p.index},right:{start:p.index,end:d},wholeMatch:{start:o,end:d}};if(u.push(p),!i)return u}}while(n&&(l.lastIndex=s));return u}function s(u){"use strict";return function(e,r,t,a,n,s,o){var i=t=t.replace(x.helper.regexes.asteriskDashAndColon,x.helper.escapeCharactersCallback),l="",c="",r=r||"",o=o||"";return/^www\./i.test(t)&&(t=t.replace(/^www\./i,"http://www.")),u.excludeTrailingPunctuationFromURLs&&s&&(l=s),r+'"+i+""+l+o}}function o(n,s){"use strict";return function(e,r,t){var a="mailto:";return r=r||"",t=x.subParser("unescapeSpecialChars")(t,n,s),n.encodeEmails?(a=x.helper.encodeEmailAddress(a+t),t=x.helper.encodeEmailAddress(t)):a+=t,r+''+t+""}}x.helper.matchRecursiveRegExp=function(e,r,t,a){"use strict";for(var n=u(e,r,t,a),s=[],o=0;o>=0,t=String(t||" "),e.length>r?String(e):((r-=e.length)>t.length&&(t+=t.repeat(r/t.length)),String(e)+t.slice(0,r))},"undefined"==typeof console&&(console={warn:function(e){"use strict";alert(e)},log:function(e){"use strict";alert(e)},error:function(e){"use strict";throw e}}),x.helper.regexes={asteriskDashAndColon:/([*_:~])/g},x.helper.emojis={"+1":"👍","-1":"👎",100:"💯",1234:"🔢","1st_place_medal":"🥇","2nd_place_medal":"🥈","3rd_place_medal":"🥉","8ball":"🎱",a:"🅰️",ab:"🆎",abc:"🔤",abcd:"🔡",accept:"🉑",aerial_tramway:"🚡",airplane:"✈️",alarm_clock:"⏰",alembic:"⚗️",alien:"👽",ambulance:"🚑",amphora:"🏺",anchor:"⚓️",angel:"👼",anger:"💢",angry:"😠",anguished:"😧",ant:"🐜",apple:"🍎",aquarius:"♒️",aries:"♈️",arrow_backward:"◀️",arrow_double_down:"⏬",arrow_double_up:"⏫",arrow_down:"⬇️",arrow_down_small:"🔽",arrow_forward:"▶️",arrow_heading_down:"⤵️",arrow_heading_up:"⤴️",arrow_left:"⬅️",arrow_lower_left:"↙️",arrow_lower_right:"↘️",arrow_right:"➡️",arrow_right_hook:"↪️",arrow_up:"⬆️",arrow_up_down:"↕️",arrow_up_small:"🔼",arrow_upper_left:"↖️",arrow_upper_right:"↗️",arrows_clockwise:"🔃",arrows_counterclockwise:"🔄",art:"🎨",articulated_lorry:"🚛",artificial_satellite:"🛰",astonished:"😲",athletic_shoe:"👟",atm:"🏧",atom_symbol:"⚛️",avocado:"🥑",b:"🅱️",baby:"👶",baby_bottle:"🍼",baby_chick:"🐤",baby_symbol:"🚼",back:"🔙",bacon:"🥓",badminton:"🏸",baggage_claim:"🛄",baguette_bread:"🥖",balance_scale:"⚖️",balloon:"🎈",ballot_box:"🗳",ballot_box_with_check:"☑️",bamboo:"🎍",banana:"🍌",bangbang:"‼️",bank:"🏦",bar_chart:"📊",barber:"💈",baseball:"⚾️",basketball:"🏀",basketball_man:"⛹️",basketball_woman:"⛹️‍♀️",bat:"🦇",bath:"🛀",bathtub:"🛁",battery:"🔋",beach_umbrella:"🏖",bear:"🐻",bed:"🛏",bee:"🐝",beer:"🍺",beers:"🍻",beetle:"🐞",beginner:"🔰",bell:"🔔",bellhop_bell:"🛎",bento:"🍱",biking_man:"🚴",bike:"🚲",biking_woman:"🚴‍♀️",bikini:"👙",biohazard:"☣️",bird:"🐦",birthday:"🎂",black_circle:"⚫️",black_flag:"🏴",black_heart:"🖤",black_joker:"🃏",black_large_square:"⬛️",black_medium_small_square:"◾️",black_medium_square:"◼️",black_nib:"✒️",black_small_square:"▪️",black_square_button:"🔲",blonde_man:"👱",blonde_woman:"👱‍♀️",blossom:"🌼",blowfish:"🐡",blue_book:"📘",blue_car:"🚙",blue_heart:"💙",blush:"😊",boar:"🐗",boat:"⛵️",bomb:"💣",book:"📖",bookmark:"🔖",bookmark_tabs:"📑",books:"📚",boom:"💥",boot:"👢",bouquet:"💐",bowing_man:"🙇",bow_and_arrow:"🏹",bowing_woman:"🙇‍♀️",bowling:"🎳",boxing_glove:"🥊",boy:"👦",bread:"🍞",bride_with_veil:"👰",bridge_at_night:"🌉",briefcase:"💼",broken_heart:"💔",bug:"🐛",building_construction:"🏗",bulb:"💡",bullettrain_front:"🚅",bullettrain_side:"🚄",burrito:"🌯",bus:"🚌",business_suit_levitating:"🕴",busstop:"🚏",bust_in_silhouette:"👤",busts_in_silhouette:"👥",butterfly:"🦋",cactus:"🌵",cake:"🍰",calendar:"📆",call_me_hand:"🤙",calling:"📲",camel:"🐫",camera:"📷",camera_flash:"📸",camping:"🏕",cancer:"♋️",candle:"🕯",candy:"🍬",canoe:"🛶",capital_abcd:"🔠",capricorn:"♑️",car:"🚗",card_file_box:"🗃",card_index:"📇",card_index_dividers:"🗂",carousel_horse:"🎠",carrot:"🥕",cat:"🐱",cat2:"🐈",cd:"💿",chains:"⛓",champagne:"🍾",chart:"💹",chart_with_downwards_trend:"📉",chart_with_upwards_trend:"📈",checkered_flag:"🏁",cheese:"🧀",cherries:"🍒",cherry_blossom:"🌸",chestnut:"🌰",chicken:"🐔",children_crossing:"🚸",chipmunk:"🐿",chocolate_bar:"🍫",christmas_tree:"🎄",church:"⛪️",cinema:"🎦",circus_tent:"🎪",city_sunrise:"🌇",city_sunset:"🌆",cityscape:"🏙",cl:"🆑",clamp:"🗜",clap:"👏",clapper:"🎬",classical_building:"🏛",clinking_glasses:"🥂",clipboard:"📋",clock1:"🕐",clock10:"🕙",clock1030:"🕥",clock11:"🕚",clock1130:"🕦",clock12:"🕛",clock1230:"🕧",clock130:"🕜",clock2:"🕑",clock230:"🕝",clock3:"🕒",clock330:"🕞",clock4:"🕓",clock430:"🕟",clock5:"🕔",clock530:"🕠",clock6:"🕕",clock630:"🕡",clock7:"🕖",clock730:"🕢",clock8:"🕗",clock830:"🕣",clock9:"🕘",clock930:"🕤",closed_book:"📕",closed_lock_with_key:"🔐",closed_umbrella:"🌂",cloud:"☁️",cloud_with_lightning:"🌩",cloud_with_lightning_and_rain:"⛈",cloud_with_rain:"🌧",cloud_with_snow:"🌨",clown_face:"🤡",clubs:"♣️",cocktail:"🍸",coffee:"☕️",coffin:"⚰️",cold_sweat:"😰",comet:"☄️",computer:"💻",computer_mouse:"🖱",confetti_ball:"🎊",confounded:"😖",confused:"😕",congratulations:"㊗️",construction:"🚧",construction_worker_man:"👷",construction_worker_woman:"👷‍♀️",control_knobs:"🎛",convenience_store:"🏪",cookie:"🍪",cool:"🆒",policeman:"👮",copyright:"©️",corn:"🌽",couch_and_lamp:"🛋",couple:"👫",couple_with_heart_woman_man:"💑",couple_with_heart_man_man:"👨‍❤️‍👨",couple_with_heart_woman_woman:"👩‍❤️‍👩",couplekiss_man_man:"👨‍❤️‍💋‍👨",couplekiss_man_woman:"💏",couplekiss_woman_woman:"👩‍❤️‍💋‍👩",cow:"🐮",cow2:"🐄",cowboy_hat_face:"🤠",crab:"🦀",crayon:"🖍",credit_card:"💳",crescent_moon:"🌙",cricket:"🏏",crocodile:"🐊",croissant:"🥐",crossed_fingers:"🤞",crossed_flags:"🎌",crossed_swords:"⚔️",crown:"👑",cry:"😢",crying_cat_face:"😿",crystal_ball:"🔮",cucumber:"🥒",cupid:"💘",curly_loop:"➰",currency_exchange:"💱",curry:"🍛",custard:"🍮",customs:"🛃",cyclone:"🌀",dagger:"🗡",dancer:"💃",dancing_women:"👯",dancing_men:"👯‍♂️",dango:"🍡",dark_sunglasses:"🕶",dart:"🎯",dash:"💨",date:"📅",deciduous_tree:"🌳",deer:"🦌",department_store:"🏬",derelict_house:"🏚",desert:"🏜",desert_island:"🏝",desktop_computer:"🖥",male_detective:"🕵️",diamond_shape_with_a_dot_inside:"💠",diamonds:"♦️",disappointed:"😞",disappointed_relieved:"😥",dizzy:"💫",dizzy_face:"😵",do_not_litter:"🚯",dog:"🐶",dog2:"🐕",dollar:"💵",dolls:"🎎",dolphin:"🐬",door:"🚪",doughnut:"🍩",dove:"🕊",dragon:"🐉",dragon_face:"🐲",dress:"👗",dromedary_camel:"🐪",drooling_face:"🤤",droplet:"💧",drum:"🥁",duck:"🦆",dvd:"📀","e-mail":"📧",eagle:"🦅",ear:"👂",ear_of_rice:"🌾",earth_africa:"🌍",earth_americas:"🌎",earth_asia:"🌏",egg:"🥚",eggplant:"🍆",eight_pointed_black_star:"✴️",eight_spoked_asterisk:"✳️",electric_plug:"🔌",elephant:"🐘",email:"✉️",end:"🔚",envelope_with_arrow:"📩",euro:"💶",european_castle:"🏰",european_post_office:"🏤",evergreen_tree:"🌲",exclamation:"❗️",expressionless:"😑",eye:"👁",eye_speech_bubble:"👁‍🗨",eyeglasses:"👓",eyes:"👀",face_with_head_bandage:"🤕",face_with_thermometer:"🤒",fist_oncoming:"👊",factory:"🏭",fallen_leaf:"🍂",family_man_woman_boy:"👪",family_man_boy:"👨‍👦",family_man_boy_boy:"👨‍👦‍👦",family_man_girl:"👨‍👧",family_man_girl_boy:"👨‍👧‍👦",family_man_girl_girl:"👨‍👧‍👧",family_man_man_boy:"👨‍👨‍👦",family_man_man_boy_boy:"👨‍👨‍👦‍👦",family_man_man_girl:"👨‍👨‍👧",family_man_man_girl_boy:"👨‍👨‍👧‍👦",family_man_man_girl_girl:"👨‍👨‍👧‍👧",family_man_woman_boy_boy:"👨‍👩‍👦‍👦",family_man_woman_girl:"👨‍👩‍👧",family_man_woman_girl_boy:"👨‍👩‍👧‍👦",family_man_woman_girl_girl:"👨‍👩‍👧‍👧",family_woman_boy:"👩‍👦",family_woman_boy_boy:"👩‍👦‍👦",family_woman_girl:"👩‍👧",family_woman_girl_boy:"👩‍👧‍👦",family_woman_girl_girl:"👩‍👧‍👧",family_woman_woman_boy:"👩‍👩‍👦",family_woman_woman_boy_boy:"👩‍👩‍👦‍👦",family_woman_woman_girl:"👩‍👩‍👧",family_woman_woman_girl_boy:"👩‍👩‍👧‍👦",family_woman_woman_girl_girl:"👩‍👩‍👧‍👧",fast_forward:"⏩",fax:"📠",fearful:"😨",feet:"🐾",female_detective:"🕵️‍♀️",ferris_wheel:"🎡",ferry:"⛴",field_hockey:"🏑",file_cabinet:"🗄",file_folder:"📁",film_projector:"📽",film_strip:"🎞",fire:"🔥",fire_engine:"🚒",fireworks:"🎆",first_quarter_moon:"🌓",first_quarter_moon_with_face:"🌛",fish:"🐟",fish_cake:"🍥",fishing_pole_and_fish:"🎣",fist_raised:"✊",fist_left:"🤛",fist_right:"🤜",flags:"🎏",flashlight:"🔦",fleur_de_lis:"⚜️",flight_arrival:"🛬",flight_departure:"🛫",floppy_disk:"💾",flower_playing_cards:"🎴",flushed:"😳",fog:"🌫",foggy:"🌁",football:"🏈",footprints:"👣",fork_and_knife:"🍴",fountain:"⛲️",fountain_pen:"🖋",four_leaf_clover:"🍀",fox_face:"🦊",framed_picture:"🖼",free:"🆓",fried_egg:"🍳",fried_shrimp:"🍤",fries:"🍟",frog:"🐸",frowning:"😦",frowning_face:"☹️",frowning_man:"🙍‍♂️",frowning_woman:"🙍",middle_finger:"🖕",fuelpump:"⛽️",full_moon:"🌕",full_moon_with_face:"🌝",funeral_urn:"⚱️",game_die:"🎲",gear:"⚙️",gem:"💎",gemini:"♊️",ghost:"👻",gift:"🎁",gift_heart:"💝",girl:"👧",globe_with_meridians:"🌐",goal_net:"🥅",goat:"🐐",golf:"⛳️",golfing_man:"🏌️",golfing_woman:"🏌️‍♀️",gorilla:"🦍",grapes:"🍇",green_apple:"🍏",green_book:"📗",green_heart:"💚",green_salad:"🥗",grey_exclamation:"❕",grey_question:"❔",grimacing:"😬",grin:"😁",grinning:"😀",guardsman:"💂",guardswoman:"💂‍♀️",guitar:"🎸",gun:"🔫",haircut_woman:"💇",haircut_man:"💇‍♂️",hamburger:"🍔",hammer:"🔨",hammer_and_pick:"⚒",hammer_and_wrench:"🛠",hamster:"🐹",hand:"✋",handbag:"👜",handshake:"🤝",hankey:"💩",hatched_chick:"🐥",hatching_chick:"🐣",headphones:"🎧",hear_no_evil:"🙉",heart:"❤️",heart_decoration:"💟",heart_eyes:"😍",heart_eyes_cat:"😻",heartbeat:"💓",heartpulse:"💗",hearts:"♥️",heavy_check_mark:"✔️",heavy_division_sign:"➗",heavy_dollar_sign:"💲",heavy_heart_exclamation:"❣️",heavy_minus_sign:"➖",heavy_multiplication_x:"✖️",heavy_plus_sign:"➕",helicopter:"🚁",herb:"🌿",hibiscus:"🌺",high_brightness:"🔆",high_heel:"👠",hocho:"🔪",hole:"🕳",honey_pot:"🍯",horse:"🐴",horse_racing:"🏇",hospital:"🏥",hot_pepper:"🌶",hotdog:"🌭",hotel:"🏨",hotsprings:"♨️",hourglass:"⌛️",hourglass_flowing_sand:"⏳",house:"🏠",house_with_garden:"🏡",houses:"🏘",hugs:"🤗",hushed:"😯",ice_cream:"🍨",ice_hockey:"🏒",ice_skate:"⛸",icecream:"🍦",id:"🆔",ideograph_advantage:"🉐",imp:"👿",inbox_tray:"📥",incoming_envelope:"📨",tipping_hand_woman:"💁",information_source:"ℹ️",innocent:"😇",interrobang:"⁉️",iphone:"📱",izakaya_lantern:"🏮",jack_o_lantern:"🎃",japan:"🗾",japanese_castle:"🏯",japanese_goblin:"👺",japanese_ogre:"👹",jeans:"👖",joy:"😂",joy_cat:"😹",joystick:"🕹",kaaba:"🕋",key:"🔑",keyboard:"⌨️",keycap_ten:"🔟",kick_scooter:"🛴",kimono:"👘",kiss:"💋",kissing:"😗",kissing_cat:"😽",kissing_closed_eyes:"😚",kissing_heart:"😘",kissing_smiling_eyes:"😙",kiwi_fruit:"🥝",koala:"🐨",koko:"🈁",label:"🏷",large_blue_circle:"🔵",large_blue_diamond:"🔷",large_orange_diamond:"🔶",last_quarter_moon:"🌗",last_quarter_moon_with_face:"🌜",latin_cross:"✝️",laughing:"😆",leaves:"🍃",ledger:"📒",left_luggage:"🛅",left_right_arrow:"↔️",leftwards_arrow_with_hook:"↩️",lemon:"🍋",leo:"♌️",leopard:"🐆",level_slider:"🎚",libra:"♎️",light_rail:"🚈",link:"🔗",lion:"🦁",lips:"👄",lipstick:"💄",lizard:"🦎",lock:"🔒",lock_with_ink_pen:"🔏",lollipop:"🍭",loop:"➿",loud_sound:"🔊",loudspeaker:"📢",love_hotel:"🏩",love_letter:"💌",low_brightness:"🔅",lying_face:"🤥",m:"Ⓜ️",mag:"🔍",mag_right:"🔎",mahjong:"🀄️",mailbox:"📫",mailbox_closed:"📪",mailbox_with_mail:"📬",mailbox_with_no_mail:"📭",man:"👨",man_artist:"👨‍🎨",man_astronaut:"👨‍🚀",man_cartwheeling:"🤸‍♂️",man_cook:"👨‍🍳",man_dancing:"🕺",man_facepalming:"🤦‍♂️",man_factory_worker:"👨‍🏭",man_farmer:"👨‍🌾",man_firefighter:"👨‍🚒",man_health_worker:"👨‍⚕️",man_in_tuxedo:"🤵",man_judge:"👨‍⚖️",man_juggling:"🤹‍♂️",man_mechanic:"👨‍🔧",man_office_worker:"👨‍💼",man_pilot:"👨‍✈️",man_playing_handball:"🤾‍♂️",man_playing_water_polo:"🤽‍♂️",man_scientist:"👨‍🔬",man_shrugging:"🤷‍♂️",man_singer:"👨‍🎤",man_student:"👨‍🎓",man_teacher:"👨‍🏫",man_technologist:"👨‍💻",man_with_gua_pi_mao:"👲",man_with_turban:"👳",tangerine:"🍊",mans_shoe:"👞",mantelpiece_clock:"🕰",maple_leaf:"🍁",martial_arts_uniform:"🥋",mask:"😷",massage_woman:"💆",massage_man:"💆‍♂️",meat_on_bone:"🍖",medal_military:"🎖",medal_sports:"🏅",mega:"📣",melon:"🍈",memo:"📝",men_wrestling:"🤼‍♂️",menorah:"🕎",mens:"🚹",metal:"🤘",metro:"🚇",microphone:"🎤",microscope:"🔬",milk_glass:"🥛",milky_way:"🌌",minibus:"🚐",minidisc:"💽",mobile_phone_off:"📴",money_mouth_face:"🤑",money_with_wings:"💸",moneybag:"💰",monkey:"🐒",monkey_face:"🐵",monorail:"🚝",moon:"🌔",mortar_board:"🎓",mosque:"🕌",motor_boat:"🛥",motor_scooter:"🛵",motorcycle:"🏍",motorway:"🛣",mount_fuji:"🗻",mountain:"⛰",mountain_biking_man:"🚵",mountain_biking_woman:"🚵‍♀️",mountain_cableway:"🚠",mountain_railway:"🚞",mountain_snow:"🏔",mouse:"🐭",mouse2:"🐁",movie_camera:"🎥",moyai:"🗿",mrs_claus:"🤶",muscle:"💪",mushroom:"🍄",musical_keyboard:"🎹",musical_note:"🎵",musical_score:"🎼",mute:"🔇",nail_care:"💅",name_badge:"📛",national_park:"🏞",nauseated_face:"🤢",necktie:"👔",negative_squared_cross_mark:"❎",nerd_face:"🤓",neutral_face:"😐",new:"🆕",new_moon:"🌑",new_moon_with_face:"🌚",newspaper:"📰",newspaper_roll:"🗞",next_track_button:"⏭",ng:"🆖",no_good_man:"🙅‍♂️",no_good_woman:"🙅",night_with_stars:"🌃",no_bell:"🔕",no_bicycles:"🚳",no_entry:"⛔️",no_entry_sign:"🚫",no_mobile_phones:"📵",no_mouth:"😶",no_pedestrians:"🚷",no_smoking:"🚭","non-potable_water":"🚱",nose:"👃",notebook:"📓",notebook_with_decorative_cover:"📔",notes:"🎶",nut_and_bolt:"🔩",o:"⭕️",o2:"🅾️",ocean:"🌊",octopus:"🐙",oden:"🍢",office:"🏢",oil_drum:"🛢",ok:"🆗",ok_hand:"👌",ok_man:"🙆‍♂️",ok_woman:"🙆",old_key:"🗝",older_man:"👴",older_woman:"👵",om:"🕉",on:"🔛",oncoming_automobile:"🚘",oncoming_bus:"🚍",oncoming_police_car:"🚔",oncoming_taxi:"🚖",open_file_folder:"📂",open_hands:"👐",open_mouth:"😮",open_umbrella:"☂️",ophiuchus:"⛎",orange_book:"📙",orthodox_cross:"☦️",outbox_tray:"📤",owl:"🦉",ox:"🐂",package:"📦",page_facing_up:"📄",page_with_curl:"📃",pager:"📟",paintbrush:"🖌",palm_tree:"🌴",pancakes:"🥞",panda_face:"🐼",paperclip:"📎",paperclips:"🖇",parasol_on_ground:"⛱",parking:"🅿️",part_alternation_mark:"〽️",partly_sunny:"⛅️",passenger_ship:"🛳",passport_control:"🛂",pause_button:"⏸",peace_symbol:"☮️",peach:"🍑",peanuts:"🥜",pear:"🍐",pen:"🖊",pencil2:"✏️",penguin:"🐧",pensive:"😔",performing_arts:"🎭",persevere:"😣",person_fencing:"🤺",pouting_woman:"🙎",phone:"☎️",pick:"⛏",pig:"🐷",pig2:"🐖",pig_nose:"🐽",pill:"💊",pineapple:"🍍",ping_pong:"🏓",pisces:"♓️",pizza:"🍕",place_of_worship:"🛐",plate_with_cutlery:"🍽",play_or_pause_button:"⏯",point_down:"👇",point_left:"👈",point_right:"👉",point_up:"☝️",point_up_2:"👆",police_car:"🚓",policewoman:"👮‍♀️",poodle:"🐩",popcorn:"🍿",post_office:"🏣",postal_horn:"📯",postbox:"📮",potable_water:"🚰",potato:"🥔",pouch:"👝",poultry_leg:"🍗",pound:"💷",rage:"😡",pouting_cat:"😾",pouting_man:"🙎‍♂️",pray:"🙏",prayer_beads:"📿",pregnant_woman:"🤰",previous_track_button:"⏮",prince:"🤴",princess:"👸",printer:"🖨",purple_heart:"💜",purse:"👛",pushpin:"📌",put_litter_in_its_place:"🚮",question:"❓",rabbit:"🐰",rabbit2:"🐇",racehorse:"🐎",racing_car:"🏎",radio:"📻",radio_button:"🔘",radioactive:"☢️",railway_car:"🚃",railway_track:"🛤",rainbow:"🌈",rainbow_flag:"🏳️‍🌈",raised_back_of_hand:"🤚",raised_hand_with_fingers_splayed:"🖐",raised_hands:"🙌",raising_hand_woman:"🙋",raising_hand_man:"🙋‍♂️",ram:"🐏",ramen:"🍜",rat:"🐀",record_button:"⏺",recycle:"♻️",red_circle:"🔴",registered:"®️",relaxed:"☺️",relieved:"😌",reminder_ribbon:"🎗",repeat:"🔁",repeat_one:"🔂",rescue_worker_helmet:"⛑",restroom:"🚻",revolving_hearts:"💞",rewind:"⏪",rhinoceros:"🦏",ribbon:"🎀",rice:"🍚",rice_ball:"🍙",rice_cracker:"🍘",rice_scene:"🎑",right_anger_bubble:"🗯",ring:"💍",robot:"🤖",rocket:"🚀",rofl:"🤣",roll_eyes:"🙄",roller_coaster:"🎢",rooster:"🐓",rose:"🌹",rosette:"🏵",rotating_light:"🚨",round_pushpin:"📍",rowing_man:"🚣",rowing_woman:"🚣‍♀️",rugby_football:"🏉",running_man:"🏃",running_shirt_with_sash:"🎽",running_woman:"🏃‍♀️",sa:"🈂️",sagittarius:"♐️",sake:"🍶",sandal:"👡",santa:"🎅",satellite:"📡",saxophone:"🎷",school:"🏫",school_satchel:"🎒",scissors:"✂️",scorpion:"🦂",scorpius:"♏️",scream:"😱",scream_cat:"🙀",scroll:"📜",seat:"💺",secret:"㊙️",see_no_evil:"🙈",seedling:"🌱",selfie:"🤳",shallow_pan_of_food:"🥘",shamrock:"☘️",shark:"🦈",shaved_ice:"🍧",sheep:"🐑",shell:"🐚",shield:"🛡",shinto_shrine:"⛩",ship:"🚢",shirt:"👕",shopping:"🛍",shopping_cart:"🛒",shower:"🚿",shrimp:"🦐",signal_strength:"📶",six_pointed_star:"🔯",ski:"🎿",skier:"⛷",skull:"💀",skull_and_crossbones:"☠️",sleeping:"😴",sleeping_bed:"🛌",sleepy:"😪",slightly_frowning_face:"🙁",slightly_smiling_face:"🙂",slot_machine:"🎰",small_airplane:"🛩",small_blue_diamond:"🔹",small_orange_diamond:"🔸",small_red_triangle:"🔺",small_red_triangle_down:"🔻",smile:"😄",smile_cat:"😸",smiley:"😃",smiley_cat:"😺",smiling_imp:"😈",smirk:"😏",smirk_cat:"😼",smoking:"🚬",snail:"🐌",snake:"🐍",sneezing_face:"🤧",snowboarder:"🏂",snowflake:"❄️",snowman:"⛄️",snowman_with_snow:"☃️",sob:"😭",soccer:"⚽️",soon:"🔜",sos:"🆘",sound:"🔉",space_invader:"👾",spades:"♠️",spaghetti:"🍝",sparkle:"❇️",sparkler:"🎇",sparkles:"✨",sparkling_heart:"💖",speak_no_evil:"🙊",speaker:"🔈",speaking_head:"🗣",speech_balloon:"💬",speedboat:"🚤",spider:"🕷",spider_web:"🕸",spiral_calendar:"🗓",spiral_notepad:"🗒",spoon:"🥄",squid:"🦑",stadium:"🏟",star:"⭐️",star2:"🌟",star_and_crescent:"☪️",star_of_david:"✡️",stars:"🌠",station:"🚉",statue_of_liberty:"🗽",steam_locomotive:"🚂",stew:"🍲",stop_button:"⏹",stop_sign:"🛑",stopwatch:"⏱",straight_ruler:"📏",strawberry:"🍓",stuck_out_tongue:"😛",stuck_out_tongue_closed_eyes:"😝",stuck_out_tongue_winking_eye:"😜",studio_microphone:"🎙",stuffed_flatbread:"🥙",sun_behind_large_cloud:"🌥",sun_behind_rain_cloud:"🌦",sun_behind_small_cloud:"🌤",sun_with_face:"🌞",sunflower:"🌻",sunglasses:"😎",sunny:"☀️",sunrise:"🌅",sunrise_over_mountains:"🌄",surfing_man:"🏄",surfing_woman:"🏄‍♀️",sushi:"🍣",suspension_railway:"🚟",sweat:"😓",sweat_drops:"💦",sweat_smile:"😅",sweet_potato:"🍠",swimming_man:"🏊",swimming_woman:"🏊‍♀️",symbols:"🔣",synagogue:"🕍",syringe:"💉",taco:"🌮",tada:"🎉",tanabata_tree:"🎋",taurus:"♉️",taxi:"🚕",tea:"🍵",telephone_receiver:"📞",telescope:"🔭",tennis:"🎾",tent:"⛺️",thermometer:"🌡",thinking:"🤔",thought_balloon:"💭",ticket:"🎫",tickets:"🎟",tiger:"🐯",tiger2:"🐅",timer_clock:"⏲",tipping_hand_man:"💁‍♂️",tired_face:"😫",tm:"™️",toilet:"🚽",tokyo_tower:"🗼",tomato:"🍅",tongue:"👅",top:"🔝",tophat:"🎩",tornado:"🌪",trackball:"🖲",tractor:"🚜",traffic_light:"🚥",train:"🚋",train2:"🚆",tram:"🚊",triangular_flag_on_post:"🚩",triangular_ruler:"📐",trident:"🔱",triumph:"😤",trolleybus:"🚎",trophy:"🏆",tropical_drink:"🍹",tropical_fish:"🐠",truck:"🚚",trumpet:"🎺",tulip:"🌷",tumbler_glass:"🥃",turkey:"🦃",turtle:"🐢",tv:"📺",twisted_rightwards_arrows:"🔀",two_hearts:"💕",two_men_holding_hands:"👬",two_women_holding_hands:"👭",u5272:"🈹",u5408:"🈴",u55b6:"🈺",u6307:"🈯️",u6708:"🈷️",u6709:"🈶",u6e80:"🈵",u7121:"🈚️",u7533:"🈸",u7981:"🈲",u7a7a:"🈳",umbrella:"☔️",unamused:"😒",underage:"🔞",unicorn:"🦄",unlock:"🔓",up:"🆙",upside_down_face:"🙃",v:"✌️",vertical_traffic_light:"🚦",vhs:"📼",vibration_mode:"📳",video_camera:"📹",video_game:"🎮",violin:"🎻",virgo:"♍️",volcano:"🌋",volleyball:"🏐",vs:"🆚",vulcan_salute:"🖖",walking_man:"🚶",walking_woman:"🚶‍♀️",waning_crescent_moon:"🌘",waning_gibbous_moon:"🌖",warning:"⚠️",wastebasket:"🗑",watch:"⌚️",water_buffalo:"🐃",watermelon:"🍉",wave:"👋",wavy_dash:"〰️",waxing_crescent_moon:"🌒",wc:"🚾",weary:"😩",wedding:"💒",weight_lifting_man:"🏋️",weight_lifting_woman:"🏋️‍♀️",whale:"🐳",whale2:"🐋",wheel_of_dharma:"☸️",wheelchair:"♿️",white_check_mark:"✅",white_circle:"⚪️",white_flag:"🏳️",white_flower:"💮",white_large_square:"⬜️",white_medium_small_square:"◽️",white_medium_square:"◻️",white_small_square:"▫️",white_square_button:"🔳",wilted_flower:"🥀",wind_chime:"🎐",wind_face:"🌬",wine_glass:"🍷",wink:"😉",wolf:"🐺",woman:"👩",woman_artist:"👩‍🎨",woman_astronaut:"👩‍🚀",woman_cartwheeling:"🤸‍♀️",woman_cook:"👩‍🍳",woman_facepalming:"🤦‍♀️",woman_factory_worker:"👩‍🏭",woman_farmer:"👩‍🌾",woman_firefighter:"👩‍🚒",woman_health_worker:"👩‍⚕️",woman_judge:"👩‍⚖️",woman_juggling:"🤹‍♀️",woman_mechanic:"👩‍🔧",woman_office_worker:"👩‍💼",woman_pilot:"👩‍✈️",woman_playing_handball:"🤾‍♀️",woman_playing_water_polo:"🤽‍♀️",woman_scientist:"👩‍🔬",woman_shrugging:"🤷‍♀️",woman_singer:"👩‍🎤",woman_student:"👩‍🎓",woman_teacher:"👩‍🏫",woman_technologist:"👩‍💻",woman_with_turban:"👳‍♀️",womans_clothes:"👚",womans_hat:"👒",women_wrestling:"🤼‍♀️",womens:"🚺",world_map:"🗺",worried:"😟",wrench:"🔧",writing_hand:"✍️",x:"❌",yellow_heart:"💛",yen:"💴",yin_yang:"☯️",yum:"😋",zap:"⚡️",zipper_mouth_face:"🤐",zzz:"💤",octocat:':octocat:',showdown:"S"},x.Converter=function(e){"use strict";var r,t,n={},i=[],l=[],o={},a=h,s={parsed:{},raw:"",format:""};for(r in e=e||{},p)p.hasOwnProperty(r)&&(n[r]=p[r]);if("object"!=typeof e)throw Error("Converter expects the passed parameter to be an object, but "+typeof e+" was passed instead.");for(t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);function c(e,r){if(r=r||null,x.helper.isString(e)){if(r=e=x.helper.stdExtName(e),x.extensions[e]){console.warn("DEPRECATION WARNING: "+e+" is an old extension that uses a deprecated loading method.Please inform the developer that the extension should be updated!");var t=x.extensions[e],a=e;if("function"==typeof t&&(t=t(new x.Converter)),x.helper.isArray(t)||(t=[t]),!(a=g(t,a)).valid)throw Error(a.error);for(var n=0;n[ \t]+¨NBSP;<"),!r){if(!window||!window.document)throw new Error("HTMLParser is undefined. If in a webworker or nodejs environment, you need to provide a WHATWG DOM and HTML such as JSDOM");r=window.document}for(var r=r.createElement("div"),t=(r.innerHTML=e,{preList:function(e){for(var r=e.querySelectorAll("pre"),t=[],a=0;a'}else t.push(r[a].innerHTML),r[a].innerHTML="",r[a].setAttribute("prenum",a.toString());return t}(r)}),a=(!function e(r){for(var t=0;t? ?(['"].*['"])?\)$/m))a="";else if(!a){if(a="#"+(t=t||r.toLowerCase().replace(/ ?\n/g," ")),x.helper.isUndefined(l.gUrls[t]))return e;a=l.gUrls[t],x.helper.isUndefined(l.gTitles[t])||(o=l.gTitles[t])}return e='"}return e=(e=(e=(e=(e=l.converter._dispatch("anchors.before",e,i,l)).replace(/\[((?:\[[^\]]*]|[^\[\]])*)] ?(?:\n *)?\[(.*?)]()()()()/g,r)).replace(/\[((?:\[[^\]]*]|[^\[\]])*)]()[ \t]*\([ \t]?<([^>]*)>(?:[ \t]*((["'])([^"]*?)\5))?[ \t]?\)/g,r)).replace(/\[((?:\[[^\]]*]|[^\[\]])*)]()[ \t]*\([ \t]??(?:[ \t]*((["'])([^"]*?)\5))?[ \t]?\)/g,r)).replace(/\[([^\[\]]+)]()()()()()/g,r),i.ghMentions&&(e=e.replace(/(^|\s)(\\)?(@([a-z\d]+(?:[a-z\d.-]+?[a-z\d]+)*))/gim,function(e,r,t,a,n){if("\\"===t)return r+a;if(!x.helper.isString(i.ghMentionsLink))throw new Error("ghMentionsLink option must be a string");t="";return r+'"+a+""})),e=l.converter._dispatch("anchors.after",e,i,l)});var i=/([*~_]+|\b)(((https?|ftp|dict):\/\/|www\.)[^'">\s]+?\.[^'">\s]+?)()(\1)?(?=\s|$)(?!["<>])/gi,l=/([*~_]+|\b)(((https?|ftp|dict):\/\/|www\.)[^'">\s]+\.[^'">\s]+?)([.!?,()\[\]])?(\1)?(?=\s|$)(?!["<>])/gi,c=/()<(((https?|ftp|dict):\/\/|www\.)[^'">\s]+)()>()/gi,m=/(^|\s)(?:mailto:)?([A-Za-z0-9!#$%&'*+-/=?^_`{|}~.]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)(?=$|\s)/gim,f=/<()(?:mailto:)?([-.\w]+@[-a-z0-9]+(\.[-a-z0-9]+)*\.[a-z]+)>/gi;x.subParser("autoLinks",function(e,r,t){"use strict";return e=(e=(e=t.converter._dispatch("autoLinks.before",e,r,t)).replace(c,s(r))).replace(f,o(r,t)),e=t.converter._dispatch("autoLinks.after",e,r,t)}),x.subParser("simplifiedAutoLinks",function(e,r,t){"use strict";return r.simplifiedAutoLink?(e=t.converter._dispatch("simplifiedAutoLinks.before",e,r,t),e=(e=r.excludeTrailingPunctuationFromURLs?e.replace(l,s(r)):e.replace(i,s(r))).replace(m,o(r,t)),t.converter._dispatch("simplifiedAutoLinks.after",e,r,t)):e}),x.subParser("blockGamut",function(e,r,t){"use strict";return e=t.converter._dispatch("blockGamut.before",e,r,t),e=x.subParser("blockQuotes")(e,r,t),e=x.subParser("headers")(e,r,t),e=x.subParser("horizontalRule")(e,r,t),e=x.subParser("lists")(e,r,t),e=x.subParser("codeBlocks")(e,r,t),e=x.subParser("tables")(e,r,t),e=x.subParser("hashHTMLBlocks")(e,r,t),e=x.subParser("paragraphs")(e,r,t),e=t.converter._dispatch("blockGamut.after",e,r,t)}),x.subParser("blockQuotes",function(e,r,t){"use strict";e=t.converter._dispatch("blockQuotes.before",e,r,t);var a=/(^ {0,3}>[ \t]?.+\n(.+\n)*\n*)+/gm;return r.splitAdjacentBlockquotes&&(a=/^ {0,3}>[\s\S]*?(?:\n\n)/gm),e=(e+="\n\n").replace(a,function(e){return e=(e=(e=e.replace(/^[ \t]*>[ \t]?/gm,"")).replace(/¨0/g,"")).replace(/^[ \t]+$/gm,""),e=x.subParser("githubCodeBlocks")(e,r,t),e=(e=(e=x.subParser("blockGamut")(e,r,t)).replace(/(^|\n)/g,"$1 ")).replace(/(\s*
[^\r]+?<\/pre>)/gm,function(e,r){return r.replace(/^  /gm,"¨0").replace(/¨0/g,"")}),x.subParser("hashBlock")("
\n"+e+"\n
",r,t)}),e=t.converter._dispatch("blockQuotes.after",e,r,t)}),x.subParser("codeBlocks",function(e,n,s){"use strict";e=s.converter._dispatch("codeBlocks.before",e,n,s);return e=(e=(e+="¨0").replace(/(?:\n\n|^)((?:(?:[ ]{4}|\t).*\n+)+)(\n*[ ]{0,3}[^ \t\n]|(?=¨0))/g,function(e,r,t){var a="\n",r=x.subParser("outdent")(r,n,s);return r=x.subParser("encodeCode")(r,n,s),r="
"+(r=(r=(r=x.subParser("detab")(r,n,s)).replace(/^\n+/g,"")).replace(/\n+$/g,""))+(a=n.omitExtraWLInCodeBlocks?"":a)+"
",x.subParser("hashBlock")(r,n,s)+t})).replace(/¨0/,""),e=s.converter._dispatch("codeBlocks.after",e,n,s)}),x.subParser("codeSpans",function(e,n,s){"use strict";return e=(e=void 0===(e=s.converter._dispatch("codeSpans.before",e,n,s))?"":e).replace(/(^|[^\\])(`+)([^\r]*?[^`])\2(?!`)/gm,function(e,r,t,a){return a=(a=a.replace(/^([ \t]*)/g,"")).replace(/[ \t]*$/g,""),a=r+""+(a=x.subParser("encodeCode")(a,n,s))+"",a=x.subParser("hashHTMLSpans")(a,n,s)}),e=s.converter._dispatch("codeSpans.after",e,n,s)}),x.subParser("completeHTMLDocument",function(e,r,t){"use strict";if(!r.completeHTMLDocument)return e;e=t.converter._dispatch("completeHTMLDocument.before",e,r,t);var a,n="html",s="\n",o="",i='\n',l="",c="";for(a in void 0!==t.metadata.parsed.doctype&&(s="\n","html"!==(n=t.metadata.parsed.doctype.toString().toLowerCase())&&"html5"!==n||(i='')),t.metadata.parsed)if(t.metadata.parsed.hasOwnProperty(a))switch(a.toLowerCase()){case"doctype":break;case"title":o=""+t.metadata.parsed.title+"\n";break;case"charset":i="html"===n||"html5"===n?'\n':'\n';break;case"language":case"lang":l=' lang="'+t.metadata.parsed[a]+'"',c+='\n';break;default:c+='\n'}return e=s+"\n\n"+o+i+c+"\n\n"+e.trim()+"\n\n",e=t.converter._dispatch("completeHTMLDocument.after",e,r,t)}),x.subParser("detab",function(e,r,t){"use strict";return e=(e=(e=(e=(e=(e=t.converter._dispatch("detab.before",e,r,t)).replace(/\t(?=\t)/g," ")).replace(/\t/g,"¨A¨B")).replace(/¨B(.+?)¨A/g,function(e,r){for(var t=r,a=4-t.length%4,n=0;n/g,">"),e=t.converter._dispatch("encodeAmpsAndAngles.after",e,r,t)}),x.subParser("encodeBackslashEscapes",function(e,r,t){"use strict";return e=(e=(e=t.converter._dispatch("encodeBackslashEscapes.before",e,r,t)).replace(/\\(\\)/g,x.helper.escapeCharactersCallback)).replace(/\\([`*_{}\[\]()>#+.!~=|:-])/g,x.helper.escapeCharactersCallback),e=t.converter._dispatch("encodeBackslashEscapes.after",e,r,t)}),x.subParser("encodeCode",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("encodeCode.before",e,r,t)).replace(/&/g,"&").replace(//g,">").replace(/([*_{}\[\]\\=~-])/g,x.helper.escapeCharactersCallback),e=t.converter._dispatch("encodeCode.after",e,r,t)}),x.subParser("escapeSpecialCharsWithinTagAttributes",function(e,r,t){"use strict";return e=(e=(e=t.converter._dispatch("escapeSpecialCharsWithinTagAttributes.before",e,r,t)).replace(/<\/?[a-z\d_:-]+(?:[\s]+[\s\S]+?)?>/gi,function(e){return e.replace(/(.)<\/?code>(?=.)/g,"$1`").replace(/([\\`*_~=|])/g,x.helper.escapeCharactersCallback)})).replace(/-]|-[^>])(?:[^-]|-[^-])*)--)>/gi,function(e){return e.replace(/([\\`*_~=|])/g,x.helper.escapeCharactersCallback)}),e=t.converter._dispatch("escapeSpecialCharsWithinTagAttributes.after",e,r,t)}),x.subParser("githubCodeBlocks",function(e,s,o){"use strict";return s.ghCodeBlocks?(e=o.converter._dispatch("githubCodeBlocks.before",e,s,o),e=(e=(e+="¨0").replace(/(?:^|\n)(?: {0,3})(```+|~~~+)(?: *)([^\s`~]*)\n([\s\S]*?)\n(?: {0,3})\1/g,function(e,r,t,a){var n=s.omitExtraWLInCodeBlocks?"":"\n";return a=x.subParser("encodeCode")(a,s,o),a="
"+(a=(a=(a=x.subParser("detab")(a,s,o)).replace(/^\n+/g,"")).replace(/\n+$/g,""))+n+"
",a=x.subParser("hashBlock")(a,s,o),"\n\n¨G"+(o.ghCodeBlocks.push({text:e,codeblock:a})-1)+"G\n\n"})).replace(/¨0/,""),o.converter._dispatch("githubCodeBlocks.after",e,s,o)):e}),x.subParser("hashBlock",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("hashBlock.before",e,r,t)).replace(/(^\n+|\n+$)/g,""),e="\n\n¨K"+(t.gHtmlBlocks.push(e)-1)+"K\n\n",e=t.converter._dispatch("hashBlock.after",e,r,t)}),x.subParser("hashCodeTags",function(e,n,s){"use strict";e=s.converter._dispatch("hashCodeTags.before",e,n,s);return e=x.helper.replaceRecursiveRegExp(e,function(e,r,t,a){t=t+x.subParser("encodeCode")(r,n,s)+a;return"¨C"+(s.gHtmlSpans.push(t)-1)+"C"},"]*>","","gim"),e=s.converter._dispatch("hashCodeTags.after",e,n,s)}),x.subParser("hashElement",function(e,r,t){"use strict";return function(e,r){return r=(r=(r=r.replace(/\n\n/g,"\n")).replace(/^\n/,"")).replace(/\n+$/g,""),r="\n\n¨K"+(t.gHtmlBlocks.push(r)-1)+"K\n\n"}}),x.subParser("hashHTMLBlocks",function(e,r,n){"use strict";e=n.converter._dispatch("hashHTMLBlocks.before",e,r,n);function t(e,r,t,a){return-1!==t.search(/\bmarkdown\b/)&&(e=t+n.converter.makeHtml(r)+a),"\n\n¨K"+(n.gHtmlBlocks.push(e)-1)+"K\n\n"}var a=["pre","div","h1","h2","h3","h4","h5","h6","blockquote","table","dl","ol","ul","script","noscript","form","fieldset","iframe","math","style","section","header","footer","nav","article","aside","address","audio","canvas","figure","hgroup","output","video","p"];r.backslashEscapesHTMLTags&&(e=e.replace(/\\<(\/?[^>]+?)>/g,function(e,r){return"<"+r+">"}));for(var s=0;s]*>)","im"),i="<"+a[s]+"\\b[^>]*>",l="";-1!==(c=x.helper.regexIndexOf(e,o));){var c=x.helper.splitAtIndex(e,c),u=x.helper.replaceRecursiveRegExp(c[1],t,i,l,"im");if(u===c[1])break;e=c[0].concat(u)}return e=e.replace(/(\n {0,3}(<(hr)\b([^<>])*?\/?>)[ \t]*(?=\n{2,}))/g,x.subParser("hashElement")(e,r,n)),e=(e=x.helper.replaceRecursiveRegExp(e,function(e){return"\n\n¨K"+(n.gHtmlBlocks.push(e)-1)+"K\n\n"},"^ {0,3}\x3c!--","--\x3e","gm")).replace(/(?:\n\n)( {0,3}(?:<([?%])[^\r]*?\2>)[ \t]*(?=\n{2,}))/g,x.subParser("hashElement")(e,r,n)),e=n.converter._dispatch("hashHTMLBlocks.after",e,r,n)}),x.subParser("hashHTMLSpans",function(e,r,t){"use strict";function a(e){return"¨C"+(t.gHtmlSpans.push(e)-1)+"C"}return e=(e=(e=(e=(e=t.converter._dispatch("hashHTMLSpans.before",e,r,t)).replace(/<[^>]+?\/>/gi,a)).replace(/<([^>]+?)>[\s\S]*?<\/\1>/g,a)).replace(/<([^>]+?)\s[^>]+?>[\s\S]*?<\/\1>/g,a)).replace(/<[^>]+?>/gi,a),e=t.converter._dispatch("hashHTMLSpans.after",e,r,t)}),x.subParser("unhashHTMLSpans",function(e,r,t){"use strict";e=t.converter._dispatch("unhashHTMLSpans.before",e,r,t);for(var a=0;a]*>\\s*]*>","^ {0,3}\\s*
","gim"),e=s.converter._dispatch("hashPreCodeTags.after",e,n,s)}),x.subParser("headers",function(e,n,s){"use strict";e=s.converter._dispatch("headers.before",e,n,s);var o=isNaN(parseInt(n.headerLevelStart))?1:parseInt(n.headerLevelStart),r=n.smoothLivePreview?/^(.+)[ \t]*\n={2,}[ \t]*\n+/gm:/^(.+)[ \t]*\n=+[ \t]*\n+/gm,t=n.smoothLivePreview?/^(.+)[ \t]*\n-{2,}[ \t]*\n+/gm:/^(.+)[ \t]*\n-+[ \t]*\n+/gm,r=(e=(e=e.replace(r,function(e,r){var t=x.subParser("spanGamut")(r,n,s),r=n.noHeaderId?"":' id="'+i(r)+'"',r=""+t+"";return x.subParser("hashBlock")(r,n,s)})).replace(t,function(e,r){var t=x.subParser("spanGamut")(r,n,s),r=n.noHeaderId?"":' id="'+i(r)+'"',a=o+1,r=""+t+"";return x.subParser("hashBlock")(r,n,s)}),n.requireSpaceBeforeHeadingText?/^(#{1,6})[ \t]+(.+?)[ \t]*#*\n+/gm:/^(#{1,6})[ \t]*(.+?)[ \t]*#*\n+/gm);function i(e){var r=e=n.customizedHeaderId&&(r=e.match(/\{([^{]+?)}\s*$/))&&r[1]?r[1]:e,e=x.helper.isString(n.prefixHeaderId)?n.prefixHeaderId:!0===n.prefixHeaderId?"section-":"";return n.rawPrefixHeaderId||(r=e+r),r=(n.ghCompatibleHeaderId?r.replace(/ /g,"-").replace(/&/g,"").replace(/¨T/g,"").replace(/¨D/g,"").replace(/[&+$,\/:;=?@"#{}|^¨~\[\]`\\*)(%.!'<>]/g,""):n.rawHeaderId?r.replace(/ /g,"-").replace(/&/g,"&").replace(/¨T/g,"¨").replace(/¨D/g,"$").replace(/["']/g,"-"):r.replace(/[^\w]/g,"")).toLowerCase(),n.rawPrefixHeaderId&&(r=e+r),s.hashLinkCounts[r]?r=r+"-"+s.hashLinkCounts[r]++:s.hashLinkCounts[r]=1,r}return e=e.replace(r,function(e,r,t){var a=t,a=(n.customizedHeaderId&&(a=t.replace(/\s?\{([^{]+?)}\s*$/,"")),x.subParser("spanGamut")(a,n,s)),t=n.noHeaderId?"":' id="'+i(t)+'"',r=o-1+r.length,t=""+a+"";return x.subParser("hashBlock")(t,n,s)}),e=s.converter._dispatch("headers.after",e,n,s)}),x.subParser("horizontalRule",function(e,r,t){"use strict";e=t.converter._dispatch("horizontalRule.before",e,r,t);var a=x.subParser("hashBlock")("
",r,t);return e=(e=(e=e.replace(/^ {0,2}( ?-){3,}[ \t]*$/gm,a)).replace(/^ {0,2}( ?\*){3,}[ \t]*$/gm,a)).replace(/^ {0,2}( ?_){3,}[ \t]*$/gm,a),e=t.converter._dispatch("horizontalRule.after",e,r,t)}),x.subParser("images",function(e,r,d){"use strict";function l(e,r,t,a,n,s,o,i){var l=d.gUrls,c=d.gTitles,u=d.gDimensions;if(t=t.toLowerCase(),i=i||"",-1? ?(['"].*['"])?\)$/m))a="";else if(""===a||null===a){if(a="#"+(t=""!==t&&null!==t?t:r.toLowerCase().replace(/ ?\n/g," ")),x.helper.isUndefined(l[t]))return e;a=l[t],x.helper.isUndefined(c[t])||(i=c[t]),x.helper.isUndefined(u[t])||(n=u[t].width,s=u[t].height)}r=r.replace(/"/g,""").replace(x.helper.regexes.asteriskDashAndColon,x.helper.escapeCharactersCallback);e=''+r+'"}return e=(e=(e=(e=(e=(e=d.converter._dispatch("images.before",e,r,d)).replace(/!\[([^\]]*?)] ?(?:\n *)?\[([\s\S]*?)]()()()()()/g,l)).replace(/!\[([^\]]*?)][ \t]*()\([ \t]??(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(["'])([^"]*?)\6)?[ \t]?\)/g,function(e,r,t,a,n,s,o,i){return l(e,r,t,a=a.replace(/\s/g,""),n,s,0,i)})).replace(/!\[([^\]]*?)][ \t]*()\([ \t]?<([^>]*)>(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(?:(["'])([^"]*?)\6))?[ \t]?\)/g,l)).replace(/!\[([^\]]*?)][ \t]*()\([ \t]??(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*(?:(["'])([^"]*?)\6)?[ \t]?\)/g,l)).replace(/!\[([^\[\]]+)]()()()()()/g,l),e=d.converter._dispatch("images.after",e,r,d)}),x.subParser("italicsAndBold",function(e,r,t){"use strict";return e=t.converter._dispatch("italicsAndBold.before",e,r,t),e=r.literalMidWordUnderscores?(e=(e=e.replace(/\b___(\S[\s\S]*?)___\b/g,function(e,r){return""+r+""})).replace(/\b__(\S[\s\S]*?)__\b/g,function(e,r){return""+r+""})).replace(/\b_(\S[\s\S]*?)_\b/g,function(e,r){return""+r+""}):(e=(e=e.replace(/___(\S[\s\S]*?)___/g,function(e,r){return/\S$/.test(r)?""+r+"":e})).replace(/__(\S[\s\S]*?)__/g,function(e,r){return/\S$/.test(r)?""+r+"":e})).replace(/_([^\s_][\s\S]*?)_/g,function(e,r){return/\S$/.test(r)?""+r+"":e}),e=r.literalMidWordAsterisks?(e=(e=e.replace(/([^*]|^)\B\*\*\*(\S[\s\S]*?)\*\*\*\B(?!\*)/g,function(e,r,t){return r+""+t+""})).replace(/([^*]|^)\B\*\*(\S[\s\S]*?)\*\*\B(?!\*)/g,function(e,r,t){return r+""+t+""})).replace(/([^*]|^)\B\*(\S[\s\S]*?)\*\B(?!\*)/g,function(e,r,t){return r+""+t+""}):(e=(e=e.replace(/\*\*\*(\S[\s\S]*?)\*\*\*/g,function(e,r){return/\S$/.test(r)?""+r+"":e})).replace(/\*\*(\S[\s\S]*?)\*\*/g,function(e,r){return/\S$/.test(r)?""+r+"":e})).replace(/\*([^\s*][\s\S]*?)\*/g,function(e,r){return/\S$/.test(r)?""+r+"":e}),e=t.converter._dispatch("italicsAndBold.after",e,r,t)}),x.subParser("lists",function(e,d,c){"use strict";function p(e,r){c.gListLevel++,e=e.replace(/\n{2,}$/,"\n");var t=/(\n)?(^ {0,3})([*+-]|\d+[.])[ \t]+((\[(x|X| )?])?[ \t]*[^\r]+?(\n{1,2}))(?=\n*(¨0| {0,3}([*+-]|\d+[.])[ \t]+))/gm,l=/\n[ \t]*\n(?!¨0)/.test(e+="¨0");return d.disableForced4SpacesIndentedSublists&&(t=/(\n)?(^ {0,3})([*+-]|\d+[.])[ \t]+((\[(x|X| )?])?[ \t]*[^\r]+?(\n{1,2}))(?=\n*(¨0|\2([*+-]|\d+[.])[ \t]+))/gm),e=(e=e.replace(t,function(e,r,t,a,n,s,o){o=o&&""!==o.trim();var n=x.subParser("outdent")(n,d,c),i="";return s&&d.tasklists&&(i=' class="task-list-item" style="list-style-type: none;"',n=n.replace(/^[ \t]*\[(x|X| )?]/m,function(){var e='"+(n=(n=r||-1\n"})).replace(/¨0/g,""),c.gListLevel--,e=r?e.replace(/\s+$/,""):e}function h(e,r){if("ol"===r){r=e.match(/^ *(\d+)\./);if(r&&"1"!==r[1])return' start="'+r[1]+'"'}return""}function n(n,s,o){var e,i=d.disableForced4SpacesIndentedSublists?/^ ?\d+\.[ \t]/gm:/^ {0,3}\d+\.[ \t]/gm,l=d.disableForced4SpacesIndentedSublists?/^ ?[*+-][ \t]/gm:/^ {0,3}[*+-][ \t]/gm,c="ul"===s?i:l,u="";return-1!==n.search(c)?function e(r){var t=r.search(c),a=h(n,s);-1!==t?(u+="\n\n<"+s+a+">\n"+p(r.slice(0,t),!!o)+"\n",c="ul"===(s="ul"===s?"ol":"ul")?i:l,e(r.slice(t))):u+="\n\n<"+s+a+">\n"+p(r,!!o)+"\n"}(n):(e=h(n,s),u="\n\n<"+s+e+">\n"+p(n,!!o)+"\n"),u}return e=c.converter._dispatch("lists.before",e,d,c),e+="¨0",e=(e=c.gListLevel?e.replace(/^(( {0,3}([*+-]|\d+[.])[ \t]+)[^\r]+?(¨0|\n{2,}(?=\S)(?![ \t]*(?:[*+-]|\d+[.])[ \t]+)))/gm,function(e,r,t){return n(r,-1"),i+="

",n.push(i))}for(s=n.length,o=0;o]*>\s*]*>/.test(c)&&(u=!0)}n[o]=c}return e=(e=(e=n.join("\n")).replace(/^\n+/g,"")).replace(/\n+$/g,""),t.converter._dispatch("paragraphs.after",e,r,t)}),x.subParser("runExtension",function(e,r,t,a){"use strict";return e.filter?r=e.filter(r,a.converter,t):e.regex&&((a=e.regex)instanceof RegExp||(a=new RegExp(a,"g")),r=r.replace(a,e.replace)),r}),x.subParser("spanGamut",function(e,r,t){"use strict";return e=t.converter._dispatch("spanGamut.before",e,r,t),e=x.subParser("codeSpans")(e,r,t),e=x.subParser("escapeSpecialCharsWithinTagAttributes")(e,r,t),e=x.subParser("encodeBackslashEscapes")(e,r,t),e=x.subParser("images")(e,r,t),e=x.subParser("anchors")(e,r,t),e=x.subParser("autoLinks")(e,r,t),e=x.subParser("simplifiedAutoLinks")(e,r,t),e=x.subParser("emoji")(e,r,t),e=x.subParser("underline")(e,r,t),e=x.subParser("italicsAndBold")(e,r,t),e=x.subParser("strikethrough")(e,r,t),e=x.subParser("ellipsis")(e,r,t),e=x.subParser("hashHTMLSpans")(e,r,t),e=x.subParser("encodeAmpsAndAngles")(e,r,t),r.simpleLineBreaks?/\n\n¨K/.test(e)||(e=e.replace(/\n+/g,"
\n")):e=e.replace(/ +\n/g,"
\n"),e=t.converter._dispatch("spanGamut.after",e,r,t)}),x.subParser("strikethrough",function(e,t,a){"use strict";return t.strikethrough&&(e=(e=a.converter._dispatch("strikethrough.before",e,t,a)).replace(/(?:~){2}([\s\S]+?)(?:~){2}/g,function(e,r){return r=r,""+(r=t.simplifiedAutoLink?x.subParser("simplifiedAutoLinks")(r,t,a):r)+""}),e=a.converter._dispatch("strikethrough.after",e,t,a)),e}),x.subParser("stripLinkDefinitions",function(i,l,c){"use strict";function e(e,r,t,a,n,s,o){return r=r.toLowerCase(),i.toLowerCase().split(r).length-1<2?e:(t.match(/^data:.+?\/.+?;base64,/)?c.gUrls[r]=t.replace(/\s/g,""):c.gUrls[r]=x.subParser("encodeAmpsAndAngles")(t,l,c),s?s+o:(o&&(c.gTitles[r]=o.replace(/"|'/g,""")),l.parseImgDimensions&&a&&n&&(c.gDimensions[r]={width:a,height:n}),""))}return i=(i=(i=(i+="¨0").replace(/^ {0,3}\[([^\]]+)]:[ \t]*\n?[ \t]*?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n\n|(?=¨0)|(?=\n\[))/gm,e)).replace(/^ {0,3}\[([^\]]+)]:[ \t]*\n?[ \t]*\s]+)>?(?: =([*\d]+[A-Za-z%]{0,4})x([*\d]+[A-Za-z%]{0,4}))?[ \t]*\n?[ \t]*(?:(\n*)["|'(](.+?)["|')][ \t]*)?(?:\n+|(?=¨0))/gm,e)).replace(/¨0/,"")}),x.subParser("tables",function(e,y,P){"use strict";if(!y.tables)return e;function r(e){for(var r=e.split("\n"),t=0;t"+(n=x.subParser("spanGamut")(n,y,P))+"\n"));for(t=0;t"+x.subParser("spanGamut")(i,y,P)+"\n"));h.push(_)}for(var m=d,f=h,b="\n\n\n",w=m.length,k=0;k\n\n\n",k=0;k\n";for(var v=0;v\n"}return b+="\n
\n"}return e=(e=(e=(e=P.converter._dispatch("tables.before",e,y,P)).replace(/\\(\|)/g,x.helper.escapeCharactersCallback)).replace(/^ {0,3}\|?.+\|.+\n {0,3}\|?[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*:?[ \t]*(?:[-=]){2,}[\s\S]+?(?:\n\n|¨0)/gm,r)).replace(/^ {0,3}\|.+\|[ \t]*\n {0,3}\|[ \t]*:?[ \t]*(?:[-=]){2,}[ \t]*:?[ \t]*\|[ \t]*\n( {0,3}\|.+\|[ \t]*\n)*(?:\n|¨0)/gm,r),e=P.converter._dispatch("tables.after",e,y,P)}),x.subParser("underline",function(e,r,t){"use strict";return r.underline?(e=t.converter._dispatch("underline.before",e,r,t),e=(e=r.literalMidWordUnderscores?(e=e.replace(/\b___(\S[\s\S]*?)___\b/g,function(e,r){return""+r+""})).replace(/\b__(\S[\s\S]*?)__\b/g,function(e,r){return""+r+""}):(e=e.replace(/___(\S[\s\S]*?)___/g,function(e,r){return/\S$/.test(r)?""+r+"":e})).replace(/__(\S[\s\S]*?)__/g,function(e,r){return/\S$/.test(r)?""+r+"":e})).replace(/(_)/g,x.helper.escapeCharactersCallback),t.converter._dispatch("underline.after",e,r,t)):e}),x.subParser("unescapeSpecialChars",function(e,r,t){"use strict";return e=(e=t.converter._dispatch("unescapeSpecialChars.before",e,r,t)).replace(/¨E(\d+)E/g,function(e,r){r=parseInt(r);return String.fromCharCode(r)}),e=t.converter._dispatch("unescapeSpecialChars.after",e,r,t)}),x.subParser("makeMarkdown.blockquote",function(e,r){"use strict";var t="";if(e.hasChildNodes())for(var a=e.childNodes,n=a.length,s=0;s ")}),x.subParser("makeMarkdown.codeBlock",function(e,r){"use strict";var t=e.getAttribute("language"),e=e.getAttribute("precodenum");return"```"+t+"\n"+r.preList[e]+"\n```"}),x.subParser("makeMarkdown.codeSpan",function(e){"use strict";return"`"+e.innerHTML+"`"}),x.subParser("makeMarkdown.emphasis",function(e,r){"use strict";var t="";if(e.hasChildNodes()){t+="*";for(var a=e.childNodes,n=a.length,s=0;s",e.hasAttribute("width")&&e.hasAttribute("height")&&(r+=" ="+e.getAttribute("width")+"x"+e.getAttribute("height")),e.hasAttribute("title")&&(r+=' "'+e.getAttribute("title")+'"'),r+=")"),r}),x.subParser("makeMarkdown.links",function(e,r){"use strict";var t="";if(e.hasChildNodes()&&e.hasAttribute("href")){for(var a=e.childNodes,n=a.length,t="[",s=0;s"),e.hasAttribute("title")&&(t+=' "'+e.getAttribute("title")+'"'),t+=")"}return t}),x.subParser("makeMarkdown.list",function(e,r,t){"use strict";var a="";if(!e.hasChildNodes())return"";for(var n=e.childNodes,s=n.length,o=e.getAttribute("start")||1,i=0;i"+r.preList[e]+""}),x.subParser("makeMarkdown.strikethrough",function(e,r){"use strict";var t="";if(e.hasChildNodes()){t+="~~";for(var a=e.childNodes,n=a.length,s=0;str>th"),s=e.querySelectorAll("tbody>tr"),o=0;o/g,"\\$1>")).replace(/^#/gm,"\\#")).replace(/^(\s*)([-=]{3,})(\s*)$/,"$1\\$2$3")).replace(/^( {0,3}\d+)\./gm,"$1\\.")).replace(/^( {0,3})([+-])/gm,"$1\\$2")).replace(/]([\s]*)\(/g,"\\]$1\\(")).replace(/^ {0,3}\[([\S \t]*?)]:/gm,"\\[$1]:")});"function"==typeof define&&define.amd?define(function(){"use strict";return x}):"undefined"!=typeof module&&module.exports?module.exports=x:this.showdown=x}.call(this); 3 | //# sourceMappingURL=showdown.min.js.map 4 | -------------------------------------------------------------------------------- /Sources/Resources/turndown.js: -------------------------------------------------------------------------------- 1 | var TurndownService = (function () { 2 | 'use strict'; 3 | 4 | function extend (destination) { 5 | for (var i = 1; i < arguments.length; i++) { 6 | var source = arguments[i]; 7 | for (var key in source) { 8 | if (source.hasOwnProperty(key)) destination[key] = source[key]; 9 | } 10 | } 11 | return destination 12 | } 13 | 14 | function repeat (character, count) { 15 | return Array(count + 1).join(character) 16 | } 17 | 18 | function trimLeadingNewlines (string) { 19 | return string.replace(/^\n*/, '') 20 | } 21 | 22 | function trimTrailingNewlines (string) { 23 | // avoid match-at-end regexp bottleneck, see #370 24 | var indexEnd = string.length; 25 | while (indexEnd > 0 && string[indexEnd - 1] === '\n') indexEnd--; 26 | return string.substring(0, indexEnd) 27 | } 28 | 29 | var blockElements = [ 30 | 'ADDRESS', 'ARTICLE', 'ASIDE', 'AUDIO', 'BLOCKQUOTE', 'BODY', 'CANVAS', 31 | 'CENTER', 'DD', 'DIR', 'DIV', 'DL', 'DT', 'FIELDSET', 'FIGCAPTION', 'FIGURE', 32 | 'FOOTER', 'FORM', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'HEADER', 33 | 'HGROUP', 'HR', 'HTML', 'ISINDEX', 'LI', 'MAIN', 'MENU', 'NAV', 'NOFRAMES', 34 | 'NOSCRIPT', 'OL', 'OUTPUT', 'P', 'PRE', 'SECTION', 'TABLE', 'TBODY', 'TD', 35 | 'TFOOT', 'TH', 'THEAD', 'TR', 'UL' 36 | ]; 37 | 38 | function isBlock (node) { 39 | return is(node, blockElements) 40 | } 41 | 42 | var voidElements = [ 43 | 'AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 44 | 'KEYGEN', 'LINK', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR' 45 | ]; 46 | 47 | function isVoid (node) { 48 | return is(node, voidElements) 49 | } 50 | 51 | function hasVoid (node) { 52 | return has(node, voidElements) 53 | } 54 | 55 | var meaningfulWhenBlankElements = [ 56 | 'A', 'TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TH', 'TD', 'IFRAME', 'SCRIPT', 57 | 'AUDIO', 'VIDEO' 58 | ]; 59 | 60 | function isMeaningfulWhenBlank (node) { 61 | return is(node, meaningfulWhenBlankElements) 62 | } 63 | 64 | function hasMeaningfulWhenBlank (node) { 65 | return has(node, meaningfulWhenBlankElements) 66 | } 67 | 68 | function is (node, tagNames) { 69 | return tagNames.indexOf(node.nodeName) >= 0 70 | } 71 | 72 | function has (node, tagNames) { 73 | return ( 74 | node.getElementsByTagName && 75 | tagNames.some(function (tagName) { 76 | return node.getElementsByTagName(tagName).length 77 | }) 78 | ) 79 | } 80 | 81 | var rules = {}; 82 | 83 | rules.paragraph = { 84 | filter: 'p', 85 | 86 | replacement: function (content) { 87 | return '\n\n' + content + '\n\n' 88 | } 89 | }; 90 | 91 | rules.lineBreak = { 92 | filter: 'br', 93 | 94 | replacement: function (content, node, options) { 95 | return options.br + '\n' 96 | } 97 | }; 98 | 99 | rules.heading = { 100 | filter: ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], 101 | 102 | replacement: function (content, node, options) { 103 | var hLevel = Number(node.nodeName.charAt(1)); 104 | 105 | if (options.headingStyle === 'setext' && hLevel < 3) { 106 | var underline = repeat((hLevel === 1 ? '=' : '-'), content.length); 107 | return ( 108 | '\n\n' + content + '\n' + underline + '\n\n' 109 | ) 110 | } else { 111 | return '\n\n' + repeat('#', hLevel) + ' ' + content + '\n\n' 112 | } 113 | } 114 | }; 115 | 116 | rules.blockquote = { 117 | filter: 'blockquote', 118 | 119 | replacement: function (content) { 120 | content = content.replace(/^\n+|\n+$/g, ''); 121 | content = content.replace(/^/gm, '> '); 122 | return '\n\n' + content + '\n\n' 123 | } 124 | }; 125 | 126 | rules.list = { 127 | filter: ['ul', 'ol'], 128 | 129 | replacement: function (content, node) { 130 | var parent = node.parentNode; 131 | if (parent.nodeName === 'LI' && parent.lastElementChild === node) { 132 | return '\n' + content 133 | } else { 134 | return '\n\n' + content + '\n\n' 135 | } 136 | } 137 | }; 138 | 139 | rules.listItem = { 140 | filter: 'li', 141 | 142 | replacement: function (content, node, options) { 143 | content = content 144 | .replace(/^\n+/, '') // remove leading newlines 145 | .replace(/\n+$/, '\n') // replace trailing newlines with just a single one 146 | .replace(/\n/gm, '\n '); // indent 147 | var prefix = options.bulletListMarker + ' '; 148 | var parent = node.parentNode; 149 | if (parent.nodeName === 'OL') { 150 | var start = parent.getAttribute('start'); 151 | var index = Array.prototype.indexOf.call(parent.children, node); 152 | prefix = (start ? Number(start) + index : index + 1) + '. '; 153 | } 154 | return ( 155 | prefix + content + (node.nextSibling && !/\n$/.test(content) ? '\n' : '') 156 | ) 157 | } 158 | }; 159 | 160 | rules.indentedCodeBlock = { 161 | filter: function (node, options) { 162 | return ( 163 | options.codeBlockStyle === 'indented' && 164 | node.nodeName === 'PRE' && 165 | node.firstChild && 166 | node.firstChild.nodeName === 'CODE' 167 | ) 168 | }, 169 | 170 | replacement: function (content, node, options) { 171 | return ( 172 | '\n\n ' + 173 | node.firstChild.textContent.replace(/\n/g, '\n ') + 174 | '\n\n' 175 | ) 176 | } 177 | }; 178 | 179 | rules.fencedCodeBlock = { 180 | filter: function (node, options) { 181 | return ( 182 | options.codeBlockStyle === 'fenced' && 183 | node.nodeName === 'PRE' && 184 | node.firstChild && 185 | node.firstChild.nodeName === 'CODE' 186 | ) 187 | }, 188 | 189 | replacement: function (content, node, options) { 190 | var className = node.firstChild.getAttribute('class') || ''; 191 | var language = (className.match(/language-(\S+)/) || [null, ''])[1]; 192 | var code = node.firstChild.textContent; 193 | 194 | var fenceChar = options.fence.charAt(0); 195 | var fenceSize = 3; 196 | var fenceInCodeRegex = new RegExp('^' + fenceChar + '{3,}', 'gm'); 197 | 198 | var match; 199 | while ((match = fenceInCodeRegex.exec(code))) { 200 | if (match[0].length >= fenceSize) { 201 | fenceSize = match[0].length + 1; 202 | } 203 | } 204 | 205 | var fence = repeat(fenceChar, fenceSize); 206 | 207 | return ( 208 | '\n\n' + fence + language + '\n' + 209 | code.replace(/\n$/, '') + 210 | '\n' + fence + '\n\n' 211 | ) 212 | } 213 | }; 214 | 215 | rules.horizontalRule = { 216 | filter: 'hr', 217 | 218 | replacement: function (content, node, options) { 219 | return '\n\n' + options.hr + '\n\n' 220 | } 221 | }; 222 | 223 | rules.inlineLink = { 224 | filter: function (node, options) { 225 | return ( 226 | options.linkStyle === 'inlined' && 227 | node.nodeName === 'A' && 228 | node.getAttribute('href') 229 | ) 230 | }, 231 | 232 | replacement: function (content, node) { 233 | var href = node.getAttribute('href'); 234 | var title = cleanAttribute(node.getAttribute('title')); 235 | if (title) title = ' "' + title + '"'; 236 | return '[' + content + '](' + href + title + ')' 237 | } 238 | }; 239 | 240 | rules.referenceLink = { 241 | filter: function (node, options) { 242 | return ( 243 | options.linkStyle === 'referenced' && 244 | node.nodeName === 'A' && 245 | node.getAttribute('href') 246 | ) 247 | }, 248 | 249 | replacement: function (content, node, options) { 250 | var href = node.getAttribute('href'); 251 | var title = cleanAttribute(node.getAttribute('title')); 252 | if (title) title = ' "' + title + '"'; 253 | var replacement; 254 | var reference; 255 | 256 | switch (options.linkReferenceStyle) { 257 | case 'collapsed': 258 | replacement = '[' + content + '][]'; 259 | reference = '[' + content + ']: ' + href + title; 260 | break 261 | case 'shortcut': 262 | replacement = '[' + content + ']'; 263 | reference = '[' + content + ']: ' + href + title; 264 | break 265 | default: 266 | var id = this.references.length + 1; 267 | replacement = '[' + content + '][' + id + ']'; 268 | reference = '[' + id + ']: ' + href + title; 269 | } 270 | 271 | this.references.push(reference); 272 | return replacement 273 | }, 274 | 275 | references: [], 276 | 277 | append: function (options) { 278 | var references = ''; 279 | if (this.references.length) { 280 | references = '\n\n' + this.references.join('\n') + '\n\n'; 281 | this.references = []; // Reset references 282 | } 283 | return references 284 | } 285 | }; 286 | 287 | rules.emphasis = { 288 | filter: ['em', 'i'], 289 | 290 | replacement: function (content, node, options) { 291 | if (!content.trim()) return '' 292 | return options.emDelimiter + content + options.emDelimiter 293 | } 294 | }; 295 | 296 | rules.strong = { 297 | filter: ['strong', 'b'], 298 | 299 | replacement: function (content, node, options) { 300 | if (!content.trim()) return '' 301 | return options.strongDelimiter + content + options.strongDelimiter 302 | } 303 | }; 304 | 305 | rules.code = { 306 | filter: function (node) { 307 | var hasSiblings = node.previousSibling || node.nextSibling; 308 | var isCodeBlock = node.parentNode.nodeName === 'PRE' && !hasSiblings; 309 | 310 | return node.nodeName === 'CODE' && !isCodeBlock 311 | }, 312 | 313 | replacement: function (content) { 314 | if (!content) return '' 315 | content = content.replace(/\r?\n|\r/g, ' '); 316 | 317 | var extraSpace = /^`|^ .*?[^ ].* $|`$/.test(content) ? ' ' : ''; 318 | var delimiter = '`'; 319 | var matches = content.match(/`+/gm) || []; 320 | while (matches.indexOf(delimiter) !== -1) delimiter = delimiter + '`'; 321 | 322 | return delimiter + extraSpace + content + extraSpace + delimiter 323 | } 324 | }; 325 | 326 | rules.image = { 327 | filter: 'img', 328 | 329 | replacement: function (content, node) { 330 | var alt = cleanAttribute(node.getAttribute('alt')); 331 | var src = node.getAttribute('src') || ''; 332 | var title = cleanAttribute(node.getAttribute('title')); 333 | var titlePart = title ? ' "' + title + '"' : ''; 334 | return src ? '![' + alt + ']' + '(' + src + titlePart + ')' : '' 335 | } 336 | }; 337 | 338 | function cleanAttribute (attribute) { 339 | return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : '' 340 | } 341 | 342 | /** 343 | * Manages a collection of rules used to convert HTML to Markdown 344 | */ 345 | 346 | function Rules (options) { 347 | this.options = options; 348 | this._keep = []; 349 | this._remove = []; 350 | 351 | this.blankRule = { 352 | replacement: options.blankReplacement 353 | }; 354 | 355 | this.keepReplacement = options.keepReplacement; 356 | 357 | this.defaultRule = { 358 | replacement: options.defaultReplacement 359 | }; 360 | 361 | this.array = []; 362 | for (var key in options.rules) this.array.push(options.rules[key]); 363 | } 364 | 365 | Rules.prototype = { 366 | add: function (key, rule) { 367 | this.array.unshift(rule); 368 | }, 369 | 370 | keep: function (filter) { 371 | this._keep.unshift({ 372 | filter: filter, 373 | replacement: this.keepReplacement 374 | }); 375 | }, 376 | 377 | remove: function (filter) { 378 | this._remove.unshift({ 379 | filter: filter, 380 | replacement: function () { 381 | return '' 382 | } 383 | }); 384 | }, 385 | 386 | forNode: function (node) { 387 | if (node.isBlank) return this.blankRule 388 | var rule; 389 | 390 | if ((rule = findRule(this.array, node, this.options))) return rule 391 | if ((rule = findRule(this._keep, node, this.options))) return rule 392 | if ((rule = findRule(this._remove, node, this.options))) return rule 393 | 394 | return this.defaultRule 395 | }, 396 | 397 | forEach: function (fn) { 398 | for (var i = 0; i < this.array.length; i++) fn(this.array[i], i); 399 | } 400 | }; 401 | 402 | function findRule (rules, node, options) { 403 | for (var i = 0; i < rules.length; i++) { 404 | var rule = rules[i]; 405 | if (filterValue(rule, node, options)) return rule 406 | } 407 | return void 0 408 | } 409 | 410 | function filterValue (rule, node, options) { 411 | var filter = rule.filter; 412 | if (typeof filter === 'string') { 413 | if (filter === node.nodeName.toLowerCase()) return true 414 | } else if (Array.isArray(filter)) { 415 | if (filter.indexOf(node.nodeName.toLowerCase()) > -1) return true 416 | } else if (typeof filter === 'function') { 417 | if (filter.call(rule, node, options)) return true 418 | } else { 419 | throw new TypeError('`filter` needs to be a string, array, or function') 420 | } 421 | } 422 | 423 | /** 424 | * The collapseWhitespace function is adapted from collapse-whitespace 425 | * by Luc Thevenard. 426 | * 427 | * The MIT License (MIT) 428 | * 429 | * Copyright (c) 2014 Luc Thevenard 430 | * 431 | * Permission is hereby granted, free of charge, to any person obtaining a copy 432 | * of this software and associated documentation files (the "Software"), to deal 433 | * in the Software without restriction, including without limitation the rights 434 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 435 | * copies of the Software, and to permit persons to whom the Software is 436 | * furnished to do so, subject to the following conditions: 437 | * 438 | * The above copyright notice and this permission notice shall be included in 439 | * all copies or substantial portions of the Software. 440 | * 441 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 442 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 443 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 444 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 445 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 446 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 447 | * THE SOFTWARE. 448 | */ 449 | 450 | /** 451 | * collapseWhitespace(options) removes extraneous whitespace from an the given element. 452 | * 453 | * @param {Object} options 454 | */ 455 | function collapseWhitespace (options) { 456 | var element = options.element; 457 | var isBlock = options.isBlock; 458 | var isVoid = options.isVoid; 459 | var isPre = options.isPre || function (node) { 460 | return node.nodeName === 'PRE' 461 | }; 462 | 463 | if (!element.firstChild || isPre(element)) return 464 | 465 | var prevText = null; 466 | var keepLeadingWs = false; 467 | 468 | var prev = null; 469 | var node = next(prev, element, isPre); 470 | 471 | while (node !== element) { 472 | if (node.nodeType === 3 || node.nodeType === 4) { // Node.TEXT_NODE or Node.CDATA_SECTION_NODE 473 | var text = node.data.replace(/[ \r\n\t]+/g, ' '); 474 | 475 | if ((!prevText || / $/.test(prevText.data)) && 476 | !keepLeadingWs && text[0] === ' ') { 477 | text = text.substr(1); 478 | } 479 | 480 | // `text` might be empty at this point. 481 | if (!text) { 482 | node = remove(node); 483 | continue 484 | } 485 | 486 | node.data = text; 487 | 488 | prevText = node; 489 | } else if (node.nodeType === 1) { // Node.ELEMENT_NODE 490 | if (isBlock(node) || node.nodeName === 'BR') { 491 | if (prevText) { 492 | prevText.data = prevText.data.replace(/ $/, ''); 493 | } 494 | 495 | prevText = null; 496 | keepLeadingWs = false; 497 | } else if (isVoid(node) || isPre(node)) { 498 | // Avoid trimming space around non-block, non-BR void elements and inline PRE. 499 | prevText = null; 500 | keepLeadingWs = true; 501 | } else if (prevText) { 502 | // Drop protection if set previously. 503 | keepLeadingWs = false; 504 | } 505 | } else { 506 | node = remove(node); 507 | continue 508 | } 509 | 510 | var nextNode = next(prev, node, isPre); 511 | prev = node; 512 | node = nextNode; 513 | } 514 | 515 | if (prevText) { 516 | prevText.data = prevText.data.replace(/ $/, ''); 517 | if (!prevText.data) { 518 | remove(prevText); 519 | } 520 | } 521 | } 522 | 523 | /** 524 | * remove(node) removes the given node from the DOM and returns the 525 | * next node in the sequence. 526 | * 527 | * @param {Node} node 528 | * @return {Node} node 529 | */ 530 | function remove (node) { 531 | var next = node.nextSibling || node.parentNode; 532 | 533 | node.parentNode.removeChild(node); 534 | 535 | return next 536 | } 537 | 538 | /** 539 | * next(prev, current, isPre) returns the next node in the sequence, given the 540 | * current and previous nodes. 541 | * 542 | * @param {Node} prev 543 | * @param {Node} current 544 | * @param {Function} isPre 545 | * @return {Node} 546 | */ 547 | function next (prev, current, isPre) { 548 | if ((prev && prev.parentNode === current) || isPre(current)) { 549 | return current.nextSibling || current.parentNode 550 | } 551 | 552 | return current.firstChild || current.nextSibling || current.parentNode 553 | } 554 | 555 | /* 556 | * Set up window for Node.js 557 | */ 558 | 559 | var root = (typeof window !== 'undefined' ? window : {}); 560 | 561 | /* 562 | * Parsing HTML strings 563 | */ 564 | 565 | function canParseHTMLNatively () { 566 | var Parser = root.DOMParser; 567 | var canParse = false; 568 | 569 | // Adapted from https://gist.github.com/1129031 570 | // Firefox/Opera/IE throw errors on unsupported types 571 | try { 572 | // WebKit returns null on unsupported types 573 | if (new Parser().parseFromString('', 'text/html')) { 574 | canParse = true; 575 | } 576 | } catch (e) {} 577 | 578 | return canParse 579 | } 580 | 581 | function createHTMLParser () { 582 | var Parser = function () {}; 583 | 584 | { 585 | if (shouldUseActiveX()) { 586 | Parser.prototype.parseFromString = function (string) { 587 | var doc = new window.ActiveXObject('htmlfile'); 588 | doc.designMode = 'on'; // disable on-page scripts 589 | doc.open(); 590 | doc.write(string); 591 | doc.close(); 592 | return doc 593 | }; 594 | } else { 595 | Parser.prototype.parseFromString = function (string) { 596 | var doc = document.implementation.createHTMLDocument(''); 597 | doc.open(); 598 | doc.write(string); 599 | doc.close(); 600 | return doc 601 | }; 602 | } 603 | } 604 | return Parser 605 | } 606 | 607 | function shouldUseActiveX () { 608 | var useActiveX = false; 609 | try { 610 | document.implementation.createHTMLDocument('').open(); 611 | } catch (e) { 612 | if (window.ActiveXObject) useActiveX = true; 613 | } 614 | return useActiveX 615 | } 616 | 617 | var HTMLParser = canParseHTMLNatively() ? root.DOMParser : createHTMLParser(); 618 | 619 | function RootNode (input, options) { 620 | var root; 621 | if (typeof input === 'string') { 622 | var doc = htmlParser().parseFromString( 623 | // DOM parsers arrange elements in the and . 624 | // Wrapping in a custom element ensures elements are reliably arranged in 625 | // a single element. 626 | '' + input + '', 627 | 'text/html' 628 | ); 629 | root = doc.getElementById('turndown-root'); 630 | } else { 631 | root = input.cloneNode(true); 632 | } 633 | collapseWhitespace({ 634 | element: root, 635 | isBlock: isBlock, 636 | isVoid: isVoid, 637 | isPre: options.preformattedCode ? isPreOrCode : null 638 | }); 639 | 640 | return root 641 | } 642 | 643 | var _htmlParser; 644 | function htmlParser () { 645 | _htmlParser = _htmlParser || new HTMLParser(); 646 | return _htmlParser 647 | } 648 | 649 | function isPreOrCode (node) { 650 | return node.nodeName === 'PRE' || node.nodeName === 'CODE' 651 | } 652 | 653 | function Node (node, options) { 654 | node.isBlock = isBlock(node); 655 | node.isCode = node.nodeName === 'CODE' || node.parentNode.isCode; 656 | node.isBlank = isBlank(node); 657 | node.flankingWhitespace = flankingWhitespace(node, options); 658 | return node 659 | } 660 | 661 | function isBlank (node) { 662 | return ( 663 | !isVoid(node) && 664 | !isMeaningfulWhenBlank(node) && 665 | /^\s*$/i.test(node.textContent) && 666 | !hasVoid(node) && 667 | !hasMeaningfulWhenBlank(node) 668 | ) 669 | } 670 | 671 | function flankingWhitespace (node, options) { 672 | if (node.isBlock || (options.preformattedCode && node.isCode)) { 673 | return { leading: '', trailing: '' } 674 | } 675 | 676 | var edges = edgeWhitespace(node.textContent); 677 | 678 | // abandon leading ASCII WS if left-flanked by ASCII WS 679 | if (edges.leadingAscii && isFlankedByWhitespace('left', node, options)) { 680 | edges.leading = edges.leadingNonAscii; 681 | } 682 | 683 | // abandon trailing ASCII WS if right-flanked by ASCII WS 684 | if (edges.trailingAscii && isFlankedByWhitespace('right', node, options)) { 685 | edges.trailing = edges.trailingNonAscii; 686 | } 687 | 688 | return { leading: edges.leading, trailing: edges.trailing } 689 | } 690 | 691 | function edgeWhitespace (string) { 692 | var m = string.match(/^(([ \t\r\n]*)(\s*))(?:(?=\S)[\s\S]*\S)?((\s*?)([ \t\r\n]*))$/); 693 | return { 694 | leading: m[1], // whole string for whitespace-only strings 695 | leadingAscii: m[2], 696 | leadingNonAscii: m[3], 697 | trailing: m[4], // empty for whitespace-only strings 698 | trailingNonAscii: m[5], 699 | trailingAscii: m[6] 700 | } 701 | } 702 | 703 | function isFlankedByWhitespace (side, node, options) { 704 | var sibling; 705 | var regExp; 706 | var isFlanked; 707 | 708 | if (side === 'left') { 709 | sibling = node.previousSibling; 710 | regExp = / $/; 711 | } else { 712 | sibling = node.nextSibling; 713 | regExp = /^ /; 714 | } 715 | 716 | if (sibling) { 717 | if (sibling.nodeType === 3) { 718 | isFlanked = regExp.test(sibling.nodeValue); 719 | } else if (options.preformattedCode && sibling.nodeName === 'CODE') { 720 | isFlanked = false; 721 | } else if (sibling.nodeType === 1 && !isBlock(sibling)) { 722 | isFlanked = regExp.test(sibling.textContent); 723 | } 724 | } 725 | return isFlanked 726 | } 727 | 728 | var reduce = Array.prototype.reduce; 729 | var escapes = [ 730 | [/\\/g, '\\\\'], 731 | [/\*/g, '\\*'], 732 | [/^-/g, '\\-'], 733 | [/^\+ /g, '\\+ '], 734 | [/^(=+)/g, '\\$1'], 735 | [/^(#{1,6}) /g, '\\$1 '], 736 | [/`/g, '\\`'], 737 | [/^~~~/g, '\\~~~'], 738 | [/\[/g, '\\['], 739 | [/\]/g, '\\]'], 740 | [/^>/g, '\\>'], 741 | [/_/g, '\\_'], 742 | [/^(\d+)\. /g, '$1\\. '] 743 | ]; 744 | 745 | function TurndownService (options) { 746 | if (!(this instanceof TurndownService)) return new TurndownService(options) 747 | 748 | var defaults = { 749 | rules: rules, 750 | headingStyle: 'setext', 751 | hr: '* * *', 752 | bulletListMarker: '*', 753 | codeBlockStyle: 'indented', 754 | fence: '```', 755 | emDelimiter: '_', 756 | strongDelimiter: '**', 757 | linkStyle: 'inlined', 758 | linkReferenceStyle: 'full', 759 | br: ' ', 760 | preformattedCode: false, 761 | blankReplacement: function (content, node) { 762 | return node.isBlock ? '\n\n' : '' 763 | }, 764 | keepReplacement: function (content, node) { 765 | return node.isBlock ? '\n\n' + node.outerHTML + '\n\n' : node.outerHTML 766 | }, 767 | defaultReplacement: function (content, node) { 768 | return node.isBlock ? '\n\n' + content + '\n\n' : content 769 | } 770 | }; 771 | this.options = extend({}, defaults, options); 772 | this.rules = new Rules(this.options); 773 | } 774 | 775 | TurndownService.prototype = { 776 | /** 777 | * The entry point for converting a string or DOM node to Markdown 778 | * @public 779 | * @param {String|HTMLElement} input The string or DOM node to convert 780 | * @returns A Markdown representation of the input 781 | * @type String 782 | */ 783 | 784 | turndown: function (input) { 785 | if (!canConvert(input)) { 786 | throw new TypeError( 787 | input + ' is not a string, or an element/document/fragment node.' 788 | ) 789 | } 790 | 791 | if (input === '') return '' 792 | 793 | var output = process.call(this, new RootNode(input, this.options)); 794 | return postProcess.call(this, output) 795 | }, 796 | 797 | /** 798 | * Add one or more plugins 799 | * @public 800 | * @param {Function|Array} plugin The plugin or array of plugins to add 801 | * @returns The Turndown instance for chaining 802 | * @type Object 803 | */ 804 | 805 | use: function (plugin) { 806 | if (Array.isArray(plugin)) { 807 | for (var i = 0; i < plugin.length; i++) this.use(plugin[i]); 808 | } else if (typeof plugin === 'function') { 809 | plugin(this); 810 | } else { 811 | throw new TypeError('plugin must be a Function or an Array of Functions') 812 | } 813 | return this 814 | }, 815 | 816 | /** 817 | * Adds a rule 818 | * @public 819 | * @param {String} key The unique key of the rule 820 | * @param {Object} rule The rule 821 | * @returns The Turndown instance for chaining 822 | * @type Object 823 | */ 824 | 825 | addRule: function (key, rule) { 826 | this.rules.add(key, rule); 827 | return this 828 | }, 829 | 830 | /** 831 | * Keep a node (as HTML) that matches the filter 832 | * @public 833 | * @param {String|Array|Function} filter The unique key of the rule 834 | * @returns The Turndown instance for chaining 835 | * @type Object 836 | */ 837 | 838 | keep: function (filter) { 839 | this.rules.keep(filter); 840 | return this 841 | }, 842 | 843 | /** 844 | * Remove a node that matches the filter 845 | * @public 846 | * @param {String|Array|Function} filter The unique key of the rule 847 | * @returns The Turndown instance for chaining 848 | * @type Object 849 | */ 850 | 851 | remove: function (filter) { 852 | this.rules.remove(filter); 853 | return this 854 | }, 855 | 856 | /** 857 | * Escapes Markdown syntax 858 | * @public 859 | * @param {String} string The string to escape 860 | * @returns A string with Markdown syntax escaped 861 | * @type String 862 | */ 863 | 864 | escape: function (string) { 865 | return escapes.reduce(function (accumulator, escape) { 866 | return accumulator.replace(escape[0], escape[1]) 867 | }, string) 868 | } 869 | }; 870 | 871 | /** 872 | * Reduces a DOM node down to its Markdown string equivalent 873 | * @private 874 | * @param {HTMLElement} parentNode The node to convert 875 | * @returns A Markdown representation of the node 876 | * @type String 877 | */ 878 | 879 | function process (parentNode) { 880 | var self = this; 881 | return reduce.call(parentNode.childNodes, function (output, node) { 882 | node = new Node(node, self.options); 883 | 884 | var replacement = ''; 885 | if (node.nodeType === 3) { 886 | replacement = node.isCode ? node.nodeValue : self.escape(node.nodeValue); 887 | } else if (node.nodeType === 1) { 888 | replacement = replacementForNode.call(self, node); 889 | } 890 | 891 | return join(output, replacement) 892 | }, '') 893 | } 894 | 895 | /** 896 | * Appends strings as each rule requires and trims the output 897 | * @private 898 | * @param {String} output The conversion output 899 | * @returns A trimmed version of the ouput 900 | * @type String 901 | */ 902 | 903 | function postProcess (output) { 904 | var self = this; 905 | this.rules.forEach(function (rule) { 906 | if (typeof rule.append === 'function') { 907 | output = join(output, rule.append(self.options)); 908 | } 909 | }); 910 | 911 | return output.replace(/^[\t\r\n]+/, '').replace(/[\t\r\n\s]+$/, '') 912 | } 913 | 914 | /** 915 | * Converts an element node to its Markdown equivalent 916 | * @private 917 | * @param {HTMLElement} node The node to convert 918 | * @returns A Markdown representation of the node 919 | * @type String 920 | */ 921 | 922 | function replacementForNode (node) { 923 | var rule = this.rules.forNode(node); 924 | var content = process.call(this, node); 925 | var whitespace = node.flankingWhitespace; 926 | if (whitespace.leading || whitespace.trailing) content = content.trim(); 927 | return ( 928 | whitespace.leading + 929 | rule.replacement(content, node, this.options) + 930 | whitespace.trailing 931 | ) 932 | } 933 | 934 | /** 935 | * Joins replacement to the current output with appropriate number of new lines 936 | * @private 937 | * @param {String} output The current conversion output 938 | * @param {String} replacement The string to append to the output 939 | * @returns Joined output 940 | * @type String 941 | */ 942 | 943 | function join (output, replacement) { 944 | var s1 = trimTrailingNewlines(output); 945 | var s2 = trimLeadingNewlines(replacement); 946 | var nls = Math.max(output.length - s1.length, replacement.length - s2.length); 947 | var separator = '\n\n'.substring(0, nls); 948 | 949 | return s1 + separator + s2 950 | } 951 | 952 | /** 953 | * Determines whether an input can be converted 954 | * @private 955 | * @param {String|HTMLElement} input Describe this parameter 956 | * @returns Describe what it returns 957 | * @type String|Object|Array|Boolean|Number 958 | */ 959 | 960 | function canConvert (input) { 961 | return ( 962 | input != null && ( 963 | typeof input === 'string' || 964 | (input.nodeType && ( 965 | input.nodeType === 1 || input.nodeType === 9 || input.nodeType === 11 966 | )) 967 | ) 968 | ) 969 | } 970 | 971 | return TurndownService; 972 | 973 | }()); 974 | -------------------------------------------------------------------------------- /Sources/module.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | @_exported import FoundationX 6 | @_exported import Swallow 7 | @_exported import WebKit 8 | -------------------------------------------------------------------------------- /Tests/GeneralTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // BrowserKit 4 | // 5 | // Created by Purav Manot on 30/04/25. 6 | // 7 | 8 | import Foundation 9 | import Testing 10 | @testable import BrowserKit 11 | 12 | struct GeneralTests { 13 | @Test("Repeated load") 14 | func testRepeatedLoads() async throws { 15 | let webView = await _BKWebView() 16 | let url = URL(string: "https://google.com")! 17 | 18 | let success = await withDiscardingTaskGroup(returning: Bool.self) { group in 19 | for _ in 0..<100 { 20 | group.addTask { 21 | let navigation = try? await webView.load(url) 22 | try? await Task.sleep(for: .milliseconds(Int.random(in: 0..<100))) 23 | print(navigation?.urlResponse) 24 | } 25 | } 26 | 27 | return true 28 | } 29 | 30 | #expect(success) 31 | } 32 | } 33 | 34 | 35 | -------------------------------------------------------------------------------- /Tests/_TurndownJS.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | import FoundationX 6 | import BrowserKit 7 | import XCTest 8 | 9 | final class _TurndownJSTests: XCTestCase { 10 | func testTurndownJS() async throws { 11 | let turndown = await _TurndownJS() 12 | 13 | let urlRequest = URLRequest(url: URL(string: "https://en.wikipedia.org/wiki/Web_scraping")!) 14 | let htmlString = try await URLSession.shared.data(for: urlRequest).0.toString() 15 | 16 | let markdown = try await turndown.convert(htmlString: htmlString) 17 | 18 | print(markdown) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/main.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) Vatsal Manot 3 | // 4 | 5 | @testable import BrowserKit 6 | 7 | import XCTest 8 | 9 | final class BrowserKitTests: XCTestCase { 10 | 11 | } 12 | --------------------------------------------------------------------------------