├── .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 |
--------------------------------------------------------------------------------