diff --git a/.gitignore b/.gitignore index a8ce1bfe..fd41ba09 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .swiftpm /*.xcodeproj xcuserdata/ +.vscode diff --git a/Documentation/HowTo/how-to-use-videos.md b/Documentation/HowTo/how-to-use-videos.md new file mode 100644 index 00000000..a8f486d6 --- /dev/null +++ b/Documentation/HowTo/how-to-use-videos.md @@ -0,0 +1,60 @@ +# How to use Videos + + +You can use videos on your pages. +You need to do 2 things: +- you define which video to include +- you define where to include the video on your page. + +## which video to include + +You need to include the video in your metadata: +``` +--- +title: Here the title of the item/page/section/index +... +video.tedTalk: celeste_headlee_10_ways_to_have_a_better_conversation +--- + +Now the content.... + +``` + +You can as well choose +- video.youtube +- video.vimeo +- video.url + +## where to include the video + +One way is to use this structure: + +```swift + public struct VideoPlayerIfNeeded : Component{ + let video: Video? + let height: String + + init(video: Video?, height: Int = 350) { + self.video = video + self.height = String(height) + } + + var body: Component { + video != nil ? VideoPlayer(video: video!,showControls: true).attribute(named:"height",value:self.height) : EmptyComponent() as Component + } + } +``` + +And then you can simply use it in your theme, e.g. + +```swift + Article { + H1(item.content.title) + VideoPlayerIfNeeded(video: item.video) + Div(item.content.body).class("content") + } +``` + + +## Alternatives +you can choose to use [this publish plugin](https://github.com/Vithanco/YoutubePublishPlugin). diff --git a/Package.resolved b/Package.resolved index d3d55a8b..1fc3bbea 100644 --- a/Package.resolved +++ b/Package.resolved @@ -30,20 +30,20 @@ }, { "package": "Ink", - "repositoryURL": "https://github.com/johnsundell/ink.git", + "repositoryURL": "https://github.com/vithanco/ink.git", "state": { "branch": null, - "revision": "77c3d8953374a9cf5418ef0bd7108524999de85a", - "version": "0.5.1" + "revision": "49b41cc5f1dde7a602c9ff63b6b4fcb48a94f2eb", + "version": "0.6.1" } }, { "package": "Plot", - "repositoryURL": "https://github.com/johnsundell/plot.git", + "repositoryURL": "https://github.com/vithanco/plot.git", "state": { "branch": null, - "revision": "80612b34252188edbef280e5375e2fc5249ac770", - "version": "0.9.0" + "revision": "ae4ca6f2ec650733d3352628aca03b728e83a3c8", + "version": "0.14.4" } }, { diff --git a/Package.swift b/Package.swift index f81064cc..02e76949 100644 --- a/Package.swift +++ b/Package.swift @@ -17,37 +17,30 @@ let package = Package( ], dependencies: [ .package( - name: "Ink", - url: "https://github.com/johnsundell/ink.git", - from: "0.2.0" + url: "https://github.com/vithanco/ink.git", + from: "0.6.1" ), .package( - name: "Plot", - url: "https://github.com/johnsundell/plot.git", - from: "0.9.0" + url: "https://github.com/vithanco/plot.git", + from: "0.14.4" ), .package( - name: "Files", url: "https://github.com/johnsundell/files.git", from: "4.0.0" ), .package( - name: "Codextended", url: "https://github.com/johnsundell/codextended.git", from: "0.1.0" ), .package( - name: "ShellOut", url: "https://github.com/johnsundell/shellout.git", from: "2.3.0" ), .package( - name: "Sweep", url: "https://github.com/johnsundell/sweep.git", from: "0.4.0" ), .package( - name: "CollectionConcurrencyKit", url: "https://github.com/johnsundell/collectionConcurrencyKit.git", from: "0.1.0" ) @@ -56,8 +49,13 @@ let package = Package( .target( name: "Publish", dependencies: [ - "Ink", "Plot", "Files", "Codextended", - "ShellOut", "Sweep", "CollectionConcurrencyKit" + .product(name: "Ink", package: "ink"), + .product(name: "Plot", package: "plot"), + .product(name: "Files", package: "files"), + .product(name: "Codextended", package: "codextended"), + .product(name: "ShellOut", package: "shellOut"), + .product(name: "Sweep", package: "sweep"), + .product(name: "CollectionConcurrencyKit", package: "collectionConcurrencyKit") ] ), .executableTarget( diff --git a/Sources/Publish/API/HTMLFileMode.swift b/Sources/Publish/API/HTMLFileMode.swift index ab26aac6..81b45f44 100644 --- a/Sources/Publish/API/HTMLFileMode.swift +++ b/Sources/Publish/API/HTMLFileMode.swift @@ -15,3 +15,23 @@ public enum HTMLFileMode { /// `section/item` becomes `section/item/index.html`. case foldersAndIndexFiles } + + +extension HTMLFileMode { + + ///Determining the right file name based on HTMLFileMode + public func filePath(for location: Location) -> Path { + return filePath(path: location.path) + } + + ///Determining the right file name based on HTMLFileMode + public func filePath(path: Path) -> Path { + switch self { + case .foldersAndIndexFiles: + return "\(path)/index.html" + case .standAloneFiles: + return "\(path).html" + } + } + +} diff --git a/Sources/Publish/API/PlotComponents.swift b/Sources/Publish/API/PlotComponents.swift index 5e0c0b09..99941b1f 100644 --- a/Sources/Publish/API/PlotComponents.swift +++ b/Sources/Publish/API/PlotComponents.swift @@ -1,8 +1,8 @@ /** -* Publish -* Copyright (c) John Sundell 2019 -* MIT license, see LICENSE file for details -*/ + * Publish + * Copyright (c) John Sundell 2019 + * MIT license, see LICENSE file for details + */ import Foundation import Plot @@ -33,19 +33,19 @@ public extension Node where Context == HTML.DocumentContext { 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), @@ -74,7 +74,7 @@ public extension Node where Context == HTML.HeadContext { 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 { @@ -88,7 +88,7 @@ public extension Node where Context: HTML.BodyContext { 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 @@ -97,7 +97,7 @@ public extension Node where Context: HTML.BodyContext { 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. @@ -109,7 +109,7 @@ public extension Node where Context: HTML.BodyContext { ) .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. @@ -147,17 +147,17 @@ internal extension Node where Context: RSSItemContext { .isPermaLink(item.rssProperties.guid == nil && item.rssProperties.link == nil) ) } - + static func content(for item: Item, 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, isHref: Bool)]() - + html.scan(using: [ Matcher( identifiers: [ @@ -169,19 +169,19 @@ internal extension Node where Context: RSSItemContext { 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) } } @@ -221,15 +221,15 @@ public extension AudioPlayer { 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) } @@ -244,7 +244,7 @@ public struct VideoPlayer: Component { /// 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. @@ -253,23 +253,26 @@ public struct VideoPlayer: Component { 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) + 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) + case .tedTalk(let id): + let url = "https://embed.ted.com/talks/" + id + return iframeVideoPlayer(for: url) } } - + private func iframeVideoPlayer(for url: URLRepresentable) -> Component { IFrame( url: url, diff --git a/Sources/Publish/API/PublishingContext.swift b/Sources/Publish/API/PublishingContext.swift index 4dd14caa..96a329bd 100644 --- a/Sources/Publish/API/PublishingContext.swift +++ b/Sources/Publish/API/PublishingContext.swift @@ -379,3 +379,14 @@ private extension PublishingContext { } } } + + +public extension PublishingContext { + var fileMode: HTMLFileMode { + return site.fileMode + } + + func tag2htmlfileName(tag: Tag) -> String { + return site.tag2htmlfileName(tag: tag) + } +} diff --git a/Sources/Publish/API/PublishingStep.swift b/Sources/Publish/API/PublishingStep.swift index 63e8a7b0..336fc355 100644 --- a/Sources/Publish/API/PublishingStep.swift +++ b/Sources/Publish/API/PublishingStep.swift @@ -363,14 +363,12 @@ public extension PublishingStep { /// - parameter fileMode: The mode to use when generating each HTML file. static func generateHTML( withTheme theme: Theme, - indentation: Indentation.Kind? = nil, - fileMode: HTMLFileMode = .foldersAndIndexFiles + indentation: Indentation.Kind? = nil ) -> Self { step(named: "Generate HTML") { context in let generator = HTMLGenerator( theme: theme, indentation: indentation, - fileMode: fileMode, context: context ) diff --git a/Sources/Publish/API/Video.swift b/Sources/Publish/API/Video.swift index 84c41c11..6e005da1 100644 --- a/Sources/Publish/API/Video.swift +++ b/Sources/Publish/API/Video.swift @@ -16,6 +16,8 @@ public enum Video: Hashable { case youTube(id: String) /// A Vimeo video with a given ID. case vimeo(id: String) + /// A Ted Talk with a given ID. + case tedTalk(id: String) } extension Video: Decodable { @@ -24,6 +26,8 @@ extension Video: Decodable { self = .youTube(id: youTubeID) } else if let vimeoID: String = try decoder.decodeIfPresent("vimeo") { self = .vimeo(id: vimeoID) + } else if let tedTalkID: String = try decoder.decodeIfPresent("tedTalk") { + self = .tedTalk(id: tedTalkID) } else { self = try .hosted( url: decoder.decode("url"), diff --git a/Sources/Publish/API/Website.swift b/Sources/Publish/API/Website.swift index fd4c5b8c..b777bb74 100644 --- a/Sources/Publish/API/Website.swift +++ b/Sources/Publish/API/Website.swift @@ -41,6 +41,8 @@ public protocol Website { /// The configuration to use when generating tag HTML for the website. /// If this is `nil`, then no tag HTML will be generated. var tagHTMLConfig: TagHTMLConfiguration? { get } + + var fileMode: HTMLFileMode {get} } // MARK: - Defaults @@ -130,67 +132,67 @@ public extension Website { semaphore.wait() return try result!.get() } - - /// Publish this website using a default pipeline. To build a completely - /// custom pipeline, use the `publish(using:)` method. - /// - parameter theme: The HTML theme to generate the website using. - /// - parameter indentation: How to indent the generated files. - /// - parameter path: Any specific path to generate the website at. - /// - parameter rssFeedSections: What sections to include in the site's RSS feed. - /// - parameter rssFeedConfig: The configuration to use for the site's RSS feed. - /// - parameter deploymentMethod: How to deploy the website. - /// - parameter additionalSteps: Any additional steps to add to the publishing - /// pipeline. Will be executed right before the HTML generation process begins. - /// - parameter plugins: Plugins to be installed at the start of the publishing process. - /// - parameter file: The file that this method is called from (auto-inserted). - /// - parameter line: The line that this method is called from (auto-inserted). - @discardableResult - func publish(withTheme theme: Theme, - indentation: Indentation.Kind? = nil, - at path: Path? = nil, - rssFeedSections: Set = Set(SectionID.allCases), - rssFeedConfig: RSSFeedConfiguration? = .default, - deployedUsing deploymentMethod: DeploymentMethod? = nil, - additionalSteps: [PublishingStep] = [], - plugins: [Plugin] = [], - file: StaticString = #file) async throws -> PublishedWebsite { - try await publish( - at: path, - using: [ - .group(plugins.map(PublishingStep.installPlugin)), - .optional(.copyResources()), - .addMarkdownFiles(), - .sortItems(by: \.date, order: .descending), - .group(additionalSteps), - .generateHTML(withTheme: theme, indentation: indentation), - .unwrap(rssFeedConfig) { config in - .generateRSSFeed( - including: rssFeedSections, - config: config - ) - }, - .generateSiteMap(indentedBy: indentation), - .unwrap(deploymentMethod, PublishingStep.deploy) - ], - file: file - ) - } - - /// Publish this website using a custom pipeline. - /// - parameter path: Any specific path to generate the website at. - /// - parameter steps: The steps to use to form the website's publishing pipeline. - /// - parameter file: The file that this method is called from (auto-inserted). - /// - parameter line: The line that this method is called from (auto-inserted). - @discardableResult - func publish(at path: Path? = nil, - using steps: [PublishingStep], - file: StaticString = #file) async throws -> PublishedWebsite { - let pipeline = PublishingPipeline( - steps: steps, - originFilePath: Path("\(file)") - ) - return try await pipeline.execute(for: self, at: path) - } +// +// /// Publish this website using a default pipeline. To build a completely +// /// custom pipeline, use the `publish(using:)` method. +// /// - parameter theme: The HTML theme to generate the website using. +// /// - parameter indentation: How to indent the generated files. +// /// - parameter path: Any specific path to generate the website at. +// /// - parameter rssFeedSections: What sections to include in the site's RSS feed. +// /// - parameter rssFeedConfig: The configuration to use for the site's RSS feed. +// /// - parameter deploymentMethod: How to deploy the website. +// /// - parameter additionalSteps: Any additional steps to add to the publishing +// /// pipeline. Will be executed right before the HTML generation process begins. +// /// - parameter plugins: Plugins to be installed at the start of the publishing process. +// /// - parameter file: The file that this method is called from (auto-inserted). +// /// - parameter line: The line that this method is called from (auto-inserted). +// @discardableResult +// func publish(withTheme theme: Theme, +// indentation: Indentation.Kind? = nil, +// at path: Path? = nil, +// rssFeedSections: Set = Set(SectionID.allCases), +// rssFeedConfig: RSSFeedConfiguration? = .default, +// deployedUsing deploymentMethod: DeploymentMethod? = nil, +// additionalSteps: [PublishingStep] = [], +// plugins: [Plugin] = [], +// file: StaticString = #file) async throws -> PublishedWebsite { +// try await publish( +// at: path, +// using: [ +// .group(plugins.map(PublishingStep.installPlugin)), +// .optional(.copyResources()), +// .addMarkdownFiles(), +// .sortItems(by: \.date, order: .descending), +// .group(additionalSteps), +// .generateHTML(withTheme: theme, indentation: indentation), +// .unwrap(rssFeedConfig) { config in +// .generateRSSFeed( +// including: rssFeedSections, +// config: config +// ) +// }, +// .generateSiteMap(indentedBy: indentation), +// .unwrap(deploymentMethod, PublishingStep.deploy) +// ], +// file: file +// ) +// } +// +// /// Publish this website using a custom pipeline. +// /// - parameter path: Any specific path to generate the website at. +// /// - parameter steps: The steps to use to form the website's publishing pipeline. +// /// - parameter file: The file that this method is called from (auto-inserted). +// /// - parameter line: The line that this method is called from (auto-inserted). +// @discardableResult +// func publish(at path: Path? = nil, +// using steps: [PublishingStep], +// file: StaticString = #file) async throws -> PublishedWebsite { +// let pipeline = PublishingPipeline( +// steps: steps, +// originFilePath: Path("\(file)") +// ) +// return try await pipeline.execute(for: self, at: path) +// } } // MARK: - Paths and URLs @@ -233,3 +235,18 @@ public extension Website { url(for: location.path) } } + + +extension Website { + public func tag2htmlfileName(tag: Tag) -> String { + let result = self.fileMode.filePath(path: self.path(for: tag)).absoluteString + //debugPrint("tag2htmlfileName: \(tag) -> \(result)") + return result + } + + public func item2htmlfileName(item: Item) -> String { + let result = self.fileMode.filePath(path: item.path).absoluteString + //debugPrint("item2htmlfileName: \(item) -> \(result)") + return result + } +} diff --git a/Sources/Publish/Internal/HTMLGenerator.swift b/Sources/Publish/Internal/HTMLGenerator.swift index 411bf428..9332120a 100644 --- a/Sources/Publish/Internal/HTMLGenerator.swift +++ b/Sources/Publish/Internal/HTMLGenerator.swift @@ -11,7 +11,9 @@ import CollectionConcurrencyKit internal struct HTMLGenerator { let theme: Theme let indentation: Indentation.Kind? - let fileMode: HTMLFileMode + var fileMode: HTMLFileMode { + return context.fileMode + } let context: PublishingContext func generate() async throws { @@ -141,11 +143,6 @@ private extension HTMLGenerator { } func filePath(for location: Location, fileMode: HTMLFileMode) -> Path { - switch fileMode { - case .foldersAndIndexFiles: - return "\(location.path)/index.html" - case .standAloneFiles: - return "\(location.path).html" - } + return fileMode.filePath(for: location) } } diff --git a/Tests/PublishTests/Infrastructure/PublishTestCase.swift b/Tests/PublishTests/Infrastructure/PublishTestCase.swift index c9f12ef5..8317c99d 100644 --- a/Tests/PublishTests/Infrastructure/PublishTestCase.swift +++ b/Tests/PublishTests/Infrastructure/PublishTestCase.swift @@ -14,13 +14,15 @@ class PublishTestCase: XCTestCase { func publishWebsite( in folder: Folder? = nil, using steps: [PublishingStep], - content: [Path : String] = [:] + content: [Path : String] = [:], + fileMode: HTMLFileMode = .foldersAndIndexFiles ) throws -> PublishedWebsite { try performWebsitePublishing( in: folder, using: steps, files: content, - filePathPrefix: "Content/" + filePathPrefix: "Content/", + fileMode : fileMode ) } @@ -197,13 +199,15 @@ private extension PublishTestCase { in folder: Folder? = nil, using steps: [PublishingStep], files: [Path : String], - filePathPrefix: String = "" + filePathPrefix: String = "", + fileMode: HTMLFileMode = .foldersAndIndexFiles ) throws -> PublishedWebsite { let folder = try folder ?? Folder.createTemporary() try addFiles(withContent: files, to: folder, pathPrefix: filePathPrefix) - - return try T().publish( + var site = T() + site.fileMode = fileMode + return try site.publish( at: Path(folder.path), using: steps ) diff --git a/Tests/PublishTests/Infrastructure/WebsiteStub.swift b/Tests/PublishTests/Infrastructure/WebsiteStub.swift index 09c3638f..13a690e6 100644 --- a/Tests/PublishTests/Infrastructure/WebsiteStub.swift +++ b/Tests/PublishTests/Infrastructure/WebsiteStub.swift @@ -20,6 +20,7 @@ class WebsiteStub { var imagePath: Path? = nil var faviconPath: Path? = nil var tagHTMLConfig: TagHTMLConfiguration? = .default + var fileMode: Publish.HTMLFileMode = .foldersAndIndexFiles required init() {} diff --git a/Tests/PublishTests/Tests/HTMLGenerationTests.swift b/Tests/PublishTests/Tests/HTMLGenerationTests.swift index 2afca997..67145fb4 100644 --- a/Tests/PublishTests/Tests/HTMLGenerationTests.swift +++ b/Tests/PublishTests/Tests/HTMLGenerationTests.swift @@ -262,8 +262,8 @@ final class HTMLGenerationTests: PublishTestCase { try publishWebsite(in: folder, using: [ .addItem(Item.stub(withPath: "item").setting(\.tags, to: ["tag"])), .addItem(Item.stub(withPath: "rawValueItem", sectionID: .customRawValue).setting(\.tags, to: ["tag"])), - .generateHTML(withTheme: theme, fileMode: .standAloneFiles) - ]) + .generateHTML(withTheme: theme) + ],fileMode: .standAloneFiles) try verifyOutput( in: folder, diff --git a/Tests/PublishTests/Tests/PlotComponentTests.swift b/Tests/PublishTests/Tests/PlotComponentTests.swift index 5269da15..6767f813 100644 --- a/Tests/PublishTests/Tests/PlotComponentTests.swift +++ b/Tests/PublishTests/Tests/PlotComponentTests.swift @@ -63,7 +63,7 @@ final class PlotComponentTests: PublishTestCase { XCTAssertEqual(html, """ """) @@ -76,7 +76,7 @@ final class PlotComponentTests: PublishTestCase { XCTAssertEqual(html, """ """)