-
Notifications
You must be signed in to change notification settings - Fork 337
/
PlotComponents.swift
286 lines (251 loc) · 10 KB
/
PlotComponents.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
/**
* Publish
* Copyright (c) John Sundell 2019
* MIT license, see LICENSE file for details
*/
import Foundation
import Plot
import Ink
import Sweep
// MARK: - Nodes and Attributes
public extension Node where Context == HTML.DocumentContext {
/// Add an HTML `<head>` tag within the current context, based
/// on inferred information from the current location and `Website`
/// implementation.
/// - parameter location: The location to generate a `<head>` tag for.
/// - parameter site: The website on which the location is located.
/// - parameter titleSeparator: Any string to use to separate the location's
/// title from the name of the website. Default: `" | "`.
/// - parameter stylesheetPaths: The paths to any stylesheets to add to
/// the resulting HTML page. Default: `styles.css`.
/// - parameter rssFeedPath: The path to any RSS feed to associate with the
/// resulting HTML page. Default: `feed.rss`.
/// - parameter rssFeedTitle: An optional title for the page's RSS feed.
static func head<T: Website>(
for location: Location,
on site: T,
titleSeparator: String = " | ",
stylesheetPaths: [Path] = ["/styles.css"],
rssFeedPath: Path? = .defaultForRSSFeed,
rssFeedTitle: String? = nil
) -> Node {
var title = location.title
if title.isEmpty {
title = site.name
} else {
title.append(titleSeparator + site.name)
}
var description = location.description
if description.isEmpty {
description = site.description
}
return .head(
.encoding(.utf8),
.siteName(site.name),
.url(site.url(for: location)),
.title(title),
.description(description),
.twitterCardType(location.imagePath == nil ? .summary : .summaryLargeImage),
.forEach(stylesheetPaths, { .stylesheet($0) }),
.viewport(.accordingToDevice),
.unwrap(site.favicon, { .favicon($0) }),
.unwrap(rssFeedPath, { path in
let title = rssFeedTitle ?? "Subscribe to \(site.name)"
return .rssFeedLink(path.absoluteString, title: title)
}),
.unwrap(location.imagePath ?? site.imagePath, { path in
let url = site.url(for: path)
return .socialImageLink(url)
})
)
}
}
public extension Node where Context == HTML.HeadContext {
/// Link the HTML page to an external CSS stylesheet.
/// - parameter path: The absolute path of the stylesheet to link to.
static func stylesheet(_ path: Path) -> Node {
.stylesheet(path.absoluteString)
}
/// Declare a favicon for the HTML page.
/// - parameter favicon: The favicon to declare.
static func favicon(_ favicon: Favicon) -> Node {
.favicon(favicon.path.absoluteString, type: favicon.type)
}
}
public extension Node where Context: HTML.BodyContext {
/// Render a location's `Content.Body` as HTML within the current context.
/// - parameter body: The body to render.
static func contentBody(_ body: Content.Body) -> Node {
.raw(body.html)
}
/// Render a string of inline Markdown as HTML within the current context.
/// - parameter markdown: The Markdown string to render.
/// - parameter parser: The Markdown parser to use. Pass `context.markdownParser` to
/// use the same Markdown parser as the main publishing process is using.
static func markdown(_ markdown: String,
using parser: MarkdownParser = .init()) -> Node {
.raw(parser.html(from: markdown))
}
/// Add an inline audio player within the current context.
/// - parameter audio: The audio to add a player for.
/// - parameter showControls: Whether playback controls should be shown to the user.
static func audioPlayer(for audio: Audio,
showControls: Bool = true) -> Node {
AudioPlayer(
audio: audio,
showControls: showControls
)
.convertToNode()
}
/// Add an inline video player within the current context.
/// - parameter video: The video to add a player for.
/// - parameter showControls: Whether playback controls should be shown to the user.
/// Note that this parameter is only relevant for hosted videos.
static func videoPlayer(for video: Video,
showControls: Bool = true) -> Node {
VideoPlayer(
video: video,
showControls: showControls
)
.convertToNode()
}
}
public extension Node where Context: HTMLLinkableContext {
/// Assign a path to link the element to, using its `href` attribute.
/// - parameter path: The absolute path to assign.
static func href(_ path: Path) -> Node {
.href(path.absoluteString)
}
}
public extension Attribute where Context: HTMLSourceContext {
/// Assign a source to the element, using its `src` attribute.
/// - parameter path: The source path to assign.
static func src(_ path: Path) -> Attribute {
.src(path.absoluteString)
}
}
internal extension Node where Context: RSSItemContext {
static func guid<T>(for item: Item<T>, site: T) -> Node {
return .guid(
.text(item.rssProperties.guid ?? site.url(for: item).absoluteString),
.isPermaLink(item.rssProperties.guid == nil && item.rssProperties.link == nil)
)
}
static func content<T>(for item: Item<T>, site: T) -> Node {
let baseURL = site.url
let prefixes = (href: "href=\"", src: "src=\"")
var html = item.rssProperties.bodyPrefix ?? ""
html.append(item.body.html)
html.append(item.rssProperties.bodySuffix ?? "")
var links = [(url: URL, range: ClosedRange<String.Index>, isHref: Bool)]()
html.scan(using: [
Matcher(
identifiers: [
.anyString(prefixes.href),
.anyString(prefixes.src)
],
terminators: ["\""],
handler: { url, range in
guard url.first == "/" else {
return
}
let absoluteURL = baseURL.appendingPathComponent(String(url))
let isHref = (html[range.lowerBound] == "h")
links.append((absoluteURL, range, isHref))
}
)
])
for (url, range, isHref) in links.reversed() {
let prefix = isHref ? prefixes.href : prefixes.src
html.replaceSubrange(range, with: prefix + url.absoluteString + "\"")
}
return content(html)
}
}
internal extension Node where Context == PodcastFeed.ItemContext {
static func duration(_ duration: Audio.Duration) -> Node {
return .duration(
hours: duration.hours,
minutes: duration.minutes,
seconds: duration.seconds
)
}
}
// MARK: - Extensions to Plot's built-in components
public extension AudioPlayer {
/// Create an inline player for an `Audio` model.
/// - parameter audio: The audio to create a player for.
/// - parameter showControls: Whether playback controls should be shown to the user.
init(audio: Audio, showControls: Bool = true) {
self.init(
source: Source(url: audio.url, format: audio.format),
showControls: showControls
)
}
}
// MARK: - New Component implementations
/// Component that can be used to parse a Markdown string into HTML
/// that's then rendered as the body of the component.
///
/// You can control what `MarkdownParser` that's used for parsing
/// using the `markdownParser` environment key, or by applying the
/// `markdownParser` modifier to a component.
public struct Markdown: Component {
/// The Markdown string to render.
public var string: String
@EnvironmentValue(.markdownParser) private var parser
/// Initialize an instance of this component with a Markdown string.
/// - parameter string: The Markdown string to render.
public init(_ string: String) {
self.string = string
}
public var body: Component {
Node.markdown(string, using: parser)
}
}
/// Component that can be used to render an inline video player, using either
/// the `<video>` element (for hosted videos), or by embedding either a YouTube
/// or Vimeo player using an `<iframe>`.
public struct VideoPlayer: Component {
/// The video to create a player for.
public var video: Video
/// Whether playback controls should be shown to the user. Note that this
/// property is ignored when rendering a video hosted by a service like YouTube.
public var showControls: Bool
/// Create an inline player for a `Video` model.
/// - parameter video: The video to create a player for.
/// - parameter showControls: Whether playback controls should be shown to the user.
/// Note that this parameter is only relevant for hosted videos.
public init(video: Video, showControls: Bool = true) {
self.video = video
self.showControls = showControls
}
public var body: Component {
switch video {
case .hosted(let url, let format):
return Node.video(
.controls(showControls),
.source(.type(format), .src(url))
)
case .youTube(let id):
let url = "https://www.youtube-nocookie.com/embed/" + id
return iframeVideoPlayer(for: url)
case .vimeo(let id):
let url = "https://player.vimeo.com/video/" + id
return iframeVideoPlayer(for: url)
}
}
private func iframeVideoPlayer(for url: URLRepresentable) -> Component {
IFrame(
url: url,
addBorder: false,
allowFullScreen: true,
enabledFeatureNames: [
"accelerometer",
"encrypted-media",
"gyroscope",
"picture-in-picture"
]
)
}
}