├── .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 | [](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
(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: ',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
[^\r]+?<\/pre>)/gm,function(e,r){return r.replace(/^ /gm,"¨0").replace(/¨0/g,"")}),x.subParser("hashBlock")("
","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="\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="
",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+""+(r=(r=(r=x.subParser("detab")(r,n,s)).replace(/^\n+/g,"")).replace(/\n+$/g,""))+(a=n.omitExtraWLInCodeBlocks?"":a)+"
"+(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="
",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"},""+(a=(a=(a=x.subParser("detab")(a,s,o)).replace(/^\n+/g,"")).replace(/\n+$/g,""))+n+"
]*>","
","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]*>","^ {0,3}
\\s*
",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"}return e=(e=(e=(e=(e=(e=d.converter._dispatch("images.before",e,r,d)).replace(/!\[([^\]]*?)] ?(?:\n *)?\[([\s\S]*?)]()()()()()/g,l)).replace(/!\[([^\]]*?)][ \t]*()\([ \t]?(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\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]?([\S]+?(?:\([\S]*?\)[\S]*?)?)>?(?: =([*\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.replace(/^([-*+]|\d\.)[ \t]+[\S\n ]*/g,function(e){return"¨A"+e}),n="
]*>/.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]*(data:.+?\/.+?;base64,[A-Za-z0-9+/=\n]+?)>?(?: =([*\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 |
--------------------------------------------------------------------------------