Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add @Image and @Video directives to reference documentation with caption support #381

Merged
merged 4 commits into from
Oct 3, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ extension RenderBlockContent: TextIndexing {
.compactMap { references[$0] as? TopicRenderReference }
.map(\.title)
.joined(separator: " ")
case .video(let video):
return video.metadata?.rawIndexableTextContent(references: references) ?? ""
default:
fatalError("unknown RenderBlockContent case in rawIndexableTextContent")
}
Expand Down
43 changes: 37 additions & 6 deletions Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1644,11 +1644,20 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
// TODO: Move this functionality to ``DocumentationBundleFileTypes`` (rdar://68156425).

/// A type of asset.
public enum AssetType {
public enum AssetType: CustomStringConvertible {
/// An image asset.
case image
/// A video asset.
case video

public var description: String {
switch self {
case .image:
return "Image"
case .video:
return "Video"
}
}
}

/// Checks if a given `fileExtension` is supported as a `type` of asset.
Expand Down Expand Up @@ -2388,12 +2397,20 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
}

/// Returns true if a resource with the given identifier exists in the registered bundle.
public func resourceExists(with identifier: ResourceReference) -> Bool{
public func resourceExists(with identifier: ResourceReference, ofType expectedAssetType: AssetType? = nil) -> Bool {
guard let assetManager = assetManagers[identifier.bundleIdentifier] else {
return false
}

return assetManager.bestKey(forAssetName: identifier.path) != nil
guard let key = assetManager.bestKey(forAssetName: identifier.path) else {
return false
}

guard let expectedAssetType = expectedAssetType, let asset = assetManager.storage[key] else {
return true
}

return asset.hasVariant(withAssetType: expectedAssetType)
}

/**
Expand Down Expand Up @@ -2603,13 +2620,19 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
/// - name: The name of the asset.
/// - parent: The topic where the asset is referenced.
/// - Returns: The data that's associated with a image asset if it was found, otherwise `nil`.
public func resolveAsset(named name: String, in parent: ResolvedTopicReference) -> DataAsset? {
public func resolveAsset(named name: String, in parent: ResolvedTopicReference, withType type: AssetType? = nil) -> DataAsset? {
let bundleIdentifier = parent.bundleIdentifier
return resolveAsset(named: name, bundleIdentifier: bundleIdentifier)
return resolveAsset(named: name, bundleIdentifier: bundleIdentifier, withType: type)
}

func resolveAsset(named name: String, bundleIdentifier: String) -> DataAsset? {
func resolveAsset(named name: String, bundleIdentifier: String, withType expectedType: AssetType?) -> DataAsset? {
if let localAsset = assetManagers[bundleIdentifier]?.allData(named: name) {
if let expectedType = expectedType {
guard localAsset.hasVariant(withAssetType: expectedType) else {
return nil
}
}

return localAsset
}

Expand Down Expand Up @@ -2802,3 +2825,11 @@ extension SymbolGraphLoader {
})
}
}

extension DataAsset {
fileprivate func hasVariant(withAssetType assetType: DocumentationContext.AssetType) -> Bool {
return variants.values.map(\.pathExtension).contains { pathExtension in
return DocumentationContext.isFileExtension(pathExtension, supported: assetType)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ public enum RenderBlockContent: Equatable {
/// A collection of authored links that should be rendered in a similar style
/// to links in an on-page Topics section.
case links(Links)

/// A video with an optional caption.
case video(Video)

// Warning: If you add a new case to this enum, make sure to handle it in the Codable
// conformance at the bottom of this file, and in the `rawIndexableTextContent` method in
Expand Down Expand Up @@ -528,6 +531,21 @@ public enum RenderBlockContent: Equatable {
self.items = items
}
}

/// A video with an optional caption.
public struct Video: Codable, Equatable {
/// A reference to the video media that should be rendered in this block.
public let identifier: RenderReferenceIdentifier

/// Any metadata associated with this video, like a caption.
public let metadata: RenderContentMetadata?

/// Create a new video with the given identifier and metadata.
public init(identifier: RenderReferenceIdentifier, metadata: RenderContentMetadata? = nil) {
self.identifier = identifier
self.metadata = metadata
}
}
}

// Writing a manual Codable implementation for tables because the encoding of `extendedData` does
Expand Down Expand Up @@ -616,6 +634,7 @@ extension RenderBlockContent: Codable {
case header, rows
case numberOfColumns, columns
case tabs
case identifier
}

public init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -682,11 +701,18 @@ extension RenderBlockContent: Codable {
items: container.decode([String].self, forKey: .items)
)
)
case .video:
self = try .video(
Video(
identifier: container.decode(RenderReferenceIdentifier.self, forKey: .identifier),
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
)
)
}
}

private enum BlockType: String, Codable {
case paragraph, aside, codeListing, heading, orderedList, unorderedList, step, endpointExample, dictionaryExample, table, termList, row, small, tabNavigator, links
case paragraph, aside, codeListing, heading, orderedList, unorderedList, step, endpointExample, dictionaryExample, table, termList, row, small, tabNavigator, links, video
}

private var type: BlockType {
Expand All @@ -706,6 +732,7 @@ extension RenderBlockContent: Codable {
case .small: return .small
case .tabNavigator: return .tabNavigator
case .links: return .links
case .video: return .video
default: fatalError("unknown RenderBlockContent case in type property")
}
}
Expand Down Expand Up @@ -761,6 +788,9 @@ extension RenderBlockContent: Codable {
case .links(let links):
try container.encode(links.style, forKey: .style)
try container.encode(links.items, forKey: .items)
case .video(let video):
try container.encode(video.identifier, forKey: .identifier)
try container.encodeIfPresent(video.metadata, forKey: .metadata)
default:
fatalError("unknown RenderBlockContent case in encode method")
}
Expand Down
43 changes: 39 additions & 4 deletions Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ struct RenderContentCompiler: MarkupVisitor {
var bundle: DocumentationBundle
var identifier: ResolvedTopicReference
var imageReferences: [String: ImageReference] = [:]
var videoReferences: [String: VideoReference] = [:]
/// Resolved topic references that were seen by the visitor. These should be used to populate the references dictionary.
var collectedTopicReferences = GroupedSequence<String, ResolvedTopicReference> { $0.absoluteString }
var linkReferences: [String: LinkReference] = [:]
Expand Down Expand Up @@ -78,14 +79,48 @@ struct RenderContentCompiler: MarkupVisitor {
}

mutating func visitImage(_ image: Image) -> [RenderContent] {
let source = image.source ?? ""
return visitImage(
source: image.source ?? "",
altText: image.altText,
caption: nil
)
}

mutating func visitImage(
source: String,
altText: String?,
caption: [RenderInlineContent]?
) -> [RenderContent] {
guard let imageIdentifier = resolveImage(source: source, altText: altText) else {
return []
}

var metadata: RenderContentMetadata?
if let caption = caption {
metadata = RenderContentMetadata(abstract: caption)
}

return [RenderInlineContent.image(identifier: imageIdentifier, metadata: metadata)]
}

mutating func resolveImage(source: String, altText: String? = nil) -> RenderReferenceIdentifier? {
let unescapedSource = source.removingPercentEncoding ?? source
let imageIdentifier: RenderReferenceIdentifier = .init(unescapedSource)
if let resolvedImages = context.resolveAsset(named: unescapedSource, in: identifier) {
imageReferences[unescapedSource] = ImageReference(identifier: imageIdentifier, altText: image.altText, imageAsset: resolvedImages)
guard let resolvedImages = context.resolveAsset(
named: unescapedSource,
in: identifier,
withType: .image
) else {
return nil
}

return [RenderInlineContent.image(identifier: imageIdentifier, metadata: nil)]
imageReferences[unescapedSource] = ImageReference(
identifier: imageIdentifier,
altText: altText,
imageAsset: resolvedImages
)

return imageIdentifier
}

mutating func visitLink(_ link: Link) -> [RenderContent] {
Expand Down
3 changes: 3 additions & 0 deletions Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
// Copy all the image references found in the markup container.
imageReferences.merge(contentCompiler.imageReferences) { (_, new) in new }
videoReferences.merge(contentCompiler.videoReferences) { (_, new) in new }
linkReferences.merge(contentCompiler.linkReferences) { (_, new) in new }
return content
}
Expand All @@ -316,6 +317,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
// Copy all the image references.
imageReferences.merge(contentCompiler.imageReferences) { (_, new) in new }
videoReferences.merge(contentCompiler.videoReferences) { (_, new) in new }
return content
}

Expand Down Expand Up @@ -1468,6 +1470,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
node.references = createTopicRenderReferences()

addReferences(imageReferences, to: &node)
addReferences(videoReferences, to: &node)
// See Also can contain external links, we need to separately transfer
// link references from the content compiler
addReferences(contentCompiler.linkReferences, to: &node)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ struct DirectiveIndex {
Small.self,
TabNavigator.self,
Links.self,
ImageMedia.self,
VideoMedia.self,
]

private static let topLevelTutorialDirectives: [AutomaticDirectiveConvertible.Type] = [
Expand Down
50 changes: 50 additions & 0 deletions Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,56 @@ struct MarkupReferenceResolver: MarkupRewriter {
} else {
return blockDirective
}
case ImageMedia.directiveName:
guard let imageMedia = ImageMedia(from: blockDirective, source: source, for: bundle, in: context) else {
return blockDirective
}

if !context.resourceExists(with: imageMedia.source, ofType: .image) {
problems.append(
unresolvedResourceProblem(
resource: imageMedia.source,
expectedType: .image,
source: source,
range: imageMedia.originalMarkup.range,
severity: .warning
)
)
}

return blockDirective
case VideoMedia.directiveName:
guard let videoMedia = VideoMedia(from: blockDirective, source: source, for: bundle, in: context) else {
return blockDirective
}

if !context.resourceExists(with: videoMedia.source, ofType: .video) {
problems.append(
unresolvedResourceProblem(
resource: videoMedia.source,
expectedType: .video,
source: source,
range: videoMedia.originalMarkup.range,
severity: .warning
)
)
}

if let posterReference = videoMedia.poster,
!context.resourceExists(with: posterReference, ofType: .image)
{
problems.append(
unresolvedResourceProblem(
resource: posterReference,
expectedType: .image,
source: source,
range: videoMedia.originalMarkup.range,
severity: .warning
)
)
}

return blockDirective
case Comment.directiveName:
return blockDirective
default:
Expand Down
29 changes: 28 additions & 1 deletion Sources/SwiftDocC/Semantics/Media/ImageMedia.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@ public final class ImageMedia: Semantic, Media, AutomaticDirectiveConvertible {
public let originalMarkup: BlockDirective

/// Optional alternate text for an image.
@DirectiveArgumentWrapped(name: .custom("alt"), required: true)
@DirectiveArgumentWrapped(name: .custom("alt"))
public private(set) var altText: String? = nil

/// An optional caption that should be rendered alongside the image.
@ChildMarkup(numberOfParagraphs: .zeroOrOne)
public private(set) var caption: MarkupContainer

@DirectiveArgumentWrapped(
parseArgument: { bundle, argumentValue in
ResourceReference(bundleIdentifier: bundle.identifier, path: argumentValue)
Expand All @@ -31,6 +35,7 @@ public final class ImageMedia: Semantic, Media, AutomaticDirectiveConvertible {
static var keyPaths: [String : AnyKeyPath] = [
"altText" : \ImageMedia._altText,
"source" : \ImageMedia._source,
"caption" : \ImageMedia._caption,
]

/// Creates a new image with the given parameters.
Expand All @@ -55,3 +60,25 @@ public final class ImageMedia: Semantic, Media, AutomaticDirectiveConvertible {
return visitor.visitImageMedia(self)
}
}

extension ImageMedia: RenderableDirectiveConvertible {
func render(with contentCompiler: inout RenderContentCompiler) -> [RenderContent] {
var renderedCaption: [RenderInlineContent]?
if let caption = caption.first {
let blockContent = contentCompiler.visit(caption)
if case let .paragraph(paragraph) = blockContent.first as? RenderBlockContent {
renderedCaption = paragraph.inlineContent
}
}

guard let renderedImage = contentCompiler.visitImage(
source: source.path,
altText: altText,
caption: renderedCaption
).first as? RenderInlineContent else {
return []
}

return [RenderBlockContent.paragraph(.init(inlineContent: [renderedImage]))]
}
}
Loading