├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── TwitterPublishPlugin │ ├── Support │ ├── EmbededTweet.swift │ ├── TweetRenderer.swift │ └── TwitterEmbedGenerator.swift │ └── TwitterPublishPlugin.swift └── Tests ├── LinuxMain.swift └── TwitterPublishPluginTests ├── TwitterPublishPluginTests.swift └── XCTestManifests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Guilherme Rambo 2 | 3 | Redistribution and use in source and binary forms, with or without 4 | modification, are permitted provided that the following conditions are met: 5 | 6 | - Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | 9 | - Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 14 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 15 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "Codextended", 6 | "repositoryURL": "https://github.com/johnsundell/codextended.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "8d7c46dfc9c55240870cf5561d6cefa41e3d7105", 10 | "version": "0.3.0" 11 | } 12 | }, 13 | { 14 | "package": "CollectionConcurrencyKit", 15 | "repositoryURL": "https://github.com/johnsundell/collectionConcurrencyKit.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "b4f23e24b5a1bff301efc5e70871083ca029ff95", 19 | "version": "0.2.0" 20 | } 21 | }, 22 | { 23 | "package": "Files", 24 | "repositoryURL": "https://github.com/johnsundell/files.git", 25 | "state": { 26 | "branch": null, 27 | "revision": "22fe84797d499ffca911ccd896b34efaf06a50b9", 28 | "version": "4.1.1" 29 | } 30 | }, 31 | { 32 | "package": "Ink", 33 | "repositoryURL": "https://github.com/johnsundell/ink.git", 34 | "state": { 35 | "branch": null, 36 | "revision": "878fd897945500be1885f2c88f81f8f909224796", 37 | "version": "0.4.0" 38 | } 39 | }, 40 | { 41 | "package": "Plot", 42 | "repositoryURL": "https://github.com/johnsundell/plot.git", 43 | "state": { 44 | "branch": null, 45 | "revision": "b358860fe565eb53e98b1f5807eb5939c8124547", 46 | "version": "0.11.0" 47 | } 48 | }, 49 | { 50 | "package": "Publish", 51 | "repositoryURL": "https://github.com/johnsundell/publish.git", 52 | "state": { 53 | "branch": null, 54 | "revision": "1c8ad00d39c985cb5d497153241a2f1b654e0d40", 55 | "version": "0.9.0" 56 | } 57 | }, 58 | { 59 | "package": "ShellOut", 60 | "repositoryURL": "https://github.com/johnsundell/shellout.git", 61 | "state": { 62 | "branch": null, 63 | "revision": "e1577acf2b6e90086d01a6d5e2b8efdaae033568", 64 | "version": "2.3.0" 65 | } 66 | }, 67 | { 68 | "package": "Sweep", 69 | "repositoryURL": "https://github.com/johnsundell/sweep.git", 70 | "state": { 71 | "branch": null, 72 | "revision": "801c2878e4c6c5baf32fe132e1f3f3af6f9fd1b0", 73 | "version": "0.4.0" 74 | } 75 | } 76 | ] 77 | }, 78 | "version": 1 79 | } 80 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.5 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "TwitterPublishPlugin", 8 | platforms: [ 9 | .macOS(.v12), 10 | ], 11 | products: [ 12 | .library( 13 | name: "TwitterPublishPlugin", 14 | targets: ["TwitterPublishPlugin"] 15 | ), 16 | ], 17 | dependencies: [ 18 | .package(name: "Publish", url: "https://github.com/johnsundell/publish.git", from: "0.8.0"), 19 | ], 20 | targets: [ 21 | .target( 22 | name: "TwitterPublishPlugin", 23 | dependencies: ["Publish"] 24 | ), 25 | .testTarget( 26 | name: "TwitterPublishPluginTests", 27 | dependencies: ["TwitterPublishPlugin"] 28 | ), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TwitterPublishPlugin 2 | 3 | A plugin for [Publish](https://github.com/JohnSundell/Publish) that let's you easily embed tweets in your posts. 4 | 5 | To embed a tweet in your post, use a blockquote in markdown, but add the "tweet" prefix, like so: 6 | 7 | ``` 8 | > tweet https://twitter.com/_inside/status/1049808231818760192 9 | ``` 10 | 11 | To install the plugin, add it to your site's publishing steps: 12 | 13 | ```swift 14 | try mysite().publish(using: [ 15 | .installPlugin(.twitter()), 16 | // ... 17 | ]) 18 | ``` -------------------------------------------------------------------------------- /Sources/TwitterPublishPlugin/Support/EmbededTweet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Guilherme Rambo on 20/02/20. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct EmbeddedTweet: Hashable, Codable { 11 | public let url: String 12 | public let authorName: String 13 | public let authorUrl: URL 14 | public let html: String 15 | public let width: Int? 16 | public let height: Int? 17 | } 18 | -------------------------------------------------------------------------------- /Sources/TwitterPublishPlugin/Support/TweetRenderer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Guilherme Rambo on 20/02/20. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol TweetRenderer { 11 | func render(tweet: EmbeddedTweet) throws -> String 12 | } 13 | 14 | public final class DefaultTweetRenderer: TweetRenderer { 15 | public init() { } 16 | public func render(tweet: EmbeddedTweet) throws -> String { tweet.html } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/TwitterPublishPlugin/Support/TwitterEmbedGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by Guilherme Rambo on 20/02/20. 6 | // 7 | 8 | import Foundation 9 | 10 | #if canImport(FoundationNetworking) 11 | import FoundationNetworking 12 | #endif 13 | 14 | final class TwitterEmbedGenerator { 15 | 16 | struct Error: LocalizedError { 17 | var localizedDescription: String 18 | 19 | static let invalidURL = Error(localizedDescription: "Failed to construct an URL") 20 | static let timeout = Error(localizedDescription: "The request to Twitter's embed API timed out") 21 | } 22 | 23 | private let session = URLSession(configuration: .default) 24 | private let baseURL = "https://publish.twitter.com/oembed?url=" 25 | 26 | let tweetURL: URL 27 | 28 | init(tweetURL: URL) { 29 | self.tweetURL = tweetURL 30 | } 31 | 32 | func generate() -> Result { 33 | guard let req = try? generateRequest(for: tweetURL) else { 34 | return .failure(.invalidURL) 35 | } 36 | 37 | var result: Result = .failure(.timeout) 38 | let sema = DispatchSemaphore(value: 0) 39 | 40 | let task = session.dataTask(with: req) { data, res, error in 41 | defer { sema.signal() } 42 | 43 | let suffix = "while processing the tweet \(self.tweetURL)" 44 | 45 | guard let res = res as? HTTPURLResponse else { 46 | result = .failure(Error(localizedDescription: "Unexpected response \(suffix)")) 47 | return 48 | } 49 | 50 | guard res.statusCode == 200 else { 51 | result = .failure(Error(localizedDescription: "Twitter's API returned error code \(res.statusCode) \(suffix)")) 52 | return 53 | } 54 | 55 | guard let data = data else { 56 | if let error = error { 57 | result = .failure(Error(localizedDescription: "The request failed with error \(error) \(suffix)")) 58 | } else { 59 | result = .failure(Error(localizedDescription: "The request returned no data \(suffix)")) 60 | } 61 | return 62 | } 63 | 64 | do { 65 | let decoder = JSONDecoder() 66 | decoder.keyDecodingStrategy = .convertFromSnakeCase 67 | 68 | let tweet = try decoder.decode(EmbeddedTweet.self, from: data) 69 | 70 | result = .success(tweet) 71 | } catch { 72 | result = .failure(Error(localizedDescription: "Error decoding: \(error) \(suffix)")) 73 | } 74 | } 75 | 76 | task.resume() 77 | 78 | _ = sema.wait(timeout: .now() + 15) 79 | 80 | return result 81 | } 82 | 83 | private func generateRequest(for tweetURL: URL) throws -> URLRequest { 84 | guard var components = URLComponents(string: baseURL) else { 85 | throw Error.invalidURL 86 | } 87 | 88 | components.queryItems = [ 89 | URLQueryItem(name: "url", value: tweetURL.absoluteString) 90 | ] 91 | 92 | guard let url = components.url else { 93 | throw Error.invalidURL 94 | } 95 | 96 | return URLRequest(url: url) 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /Sources/TwitterPublishPlugin/TwitterPublishPlugin.swift: -------------------------------------------------------------------------------- 1 | /** 2 | * Twitter plugin for Publish 3 | * © 2020 Guilherme Rambo 4 | * BSD-2 license, see LICENSE file for details 5 | */ 6 | 7 | import Publish 8 | import Ink 9 | import Foundation 10 | 11 | public extension Plugin { 12 | static func twitter(renderer: TweetRenderer = DefaultTweetRenderer()) -> Self { 13 | Plugin(name: "Twitter") { context in 14 | context.markdownParser.addModifier( 15 | .twitterBlockquote(using: renderer) 16 | ) 17 | } 18 | } 19 | } 20 | 21 | public extension Modifier { 22 | static func twitterBlockquote(using renderer: TweetRenderer) -> Self { 23 | return Modifier(target: .blockquotes) { html, markdown in 24 | let prefix = "tweet " 25 | var markdown = markdown.dropFirst().trimmingCharacters(in: .whitespaces) 26 | 27 | guard markdown.hasPrefix(prefix) else { 28 | return html 29 | } 30 | 31 | markdown = markdown.dropFirst(prefix.count).trimmingCharacters(in: .newlines) 32 | 33 | guard let url = URL(string: markdown) else { 34 | fatalError("Invalid tweet URL \(markdown)") 35 | } 36 | 37 | let generator = TwitterEmbedGenerator(tweetURL: url) 38 | 39 | let tweet = try! generator.generate().get() 40 | 41 | return try! renderer.render(tweet: tweet) 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import TwitterPublishPluginTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += TwitterPublishPluginTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /Tests/TwitterPublishPluginTests/TwitterPublishPluginTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import TwitterPublishPlugin 3 | 4 | final class TwitterPublishPluginTests: XCTestCase { } 5 | -------------------------------------------------------------------------------- /Tests/TwitterPublishPluginTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(TwitterPublishPluginTests.allTests), 7 | ] 8 | } 9 | #endif 10 | --------------------------------------------------------------------------------