Skip to content

Commit f7fd222

Browse files
committed
Support @Image and @Video directives in reference docs
Adds support for using the `@Image` and `@Video` directives from Tutorials in regular reference documentation. This allows authors to insert videos into documentation and to create images that have attached captions. To that point, the `@Image` and `@Video` directives can now contain inline text to form a caption. The final change here is that the `@Video` directive now supports alt text. Examples: @image(source: "overview-hero.png", alt: "An illustration of a sleeping sloth.") { This is a caption adding additional context for the image. } @video(source: "sloth-smiling.mp4", poster: "happy-sloth-frame.png", alt: "A short video of a sloth jumping down from a branch.") { A *happy* sloth. 🦥 } This change is described on the Swift forums here: https://forums.swift.org/t/supporting-more-dynamic-content-in-swift-docc-reference-documentation/59527#image-1 Dependencies: - swiftlang/swift-docc-render#407 - swiftlang/swift-docc-render#404 Resolves rdar://97739628
1 parent bb922b7 commit f7fd222

File tree

15 files changed

+522
-18
lines changed

15 files changed

+522
-18
lines changed

Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ extension RenderBlockContent: TextIndexing {
7575
.compactMap { references[$0] as? TopicRenderReference }
7676
.map(\.title)
7777
.joined(separator: " ")
78+
case .video(let video):
79+
return video.metadata?.rawIndexableTextContent(references: references) ?? ""
7880
default:
7981
fatalError("unknown RenderBlockContent case in rawIndexableTextContent")
8082
}

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1644,11 +1644,20 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
16441644
// TODO: Move this functionality to ``DocumentationBundleFileTypes`` (rdar://68156425).
16451645

16461646
/// A type of asset.
1647-
public enum AssetType {
1647+
public enum AssetType: CustomStringConvertible {
16481648
/// An image asset.
16491649
case image
16501650
/// A video asset.
16511651
case video
1652+
1653+
public var description: String {
1654+
switch self {
1655+
case .image:
1656+
return "Image"
1657+
case .video:
1658+
return "Video"
1659+
}
1660+
}
16521661
}
16531662

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

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

2396-
return assetManager.bestKey(forAssetName: identifier.path) != nil
2405+
guard let key = assetManager.bestKey(forAssetName: identifier.path) else {
2406+
return false
2407+
}
2408+
2409+
guard let expectedAssetType = expectedAssetType, let asset = assetManager.storage[key] else {
2410+
return true
2411+
}
2412+
2413+
return asset.hasVariant(withAssetType: expectedAssetType)
23972414
}
23982415

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

2611-
func resolveAsset(named name: String, bundleIdentifier: String) -> DataAsset? {
2628+
func resolveAsset(named name: String, bundleIdentifier: String, withType expectedType: AssetType?) -> DataAsset? {
26122629
if let localAsset = assetManagers[bundleIdentifier]?.allData(named: name) {
2630+
if let expectedType = expectedType {
2631+
guard localAsset.hasVariant(withAssetType: expectedType) else {
2632+
return nil
2633+
}
2634+
}
2635+
26132636
return localAsset
26142637
}
26152638

@@ -2802,3 +2825,11 @@ extension SymbolGraphLoader {
28022825
})
28032826
}
28042827
}
2828+
2829+
extension DataAsset {
2830+
fileprivate func hasVariant(withAssetType assetType: DocumentationContext.AssetType) -> Bool {
2831+
return variants.values.map(\.pathExtension).contains { pathExtension in
2832+
return DocumentationContext.isFileExtension(pathExtension, supported: assetType)
2833+
}
2834+
}
2835+
}

Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ public enum RenderBlockContent: Equatable {
7373
/// A collection of authored links that should be rendered in a similar style
7474
/// to links in an on-page Topics section.
7575
case links(Links)
76+
77+
/// A video with an optional caption.
78+
case video(Video)
7679

7780
// Warning: If you add a new case to this enum, make sure to handle it in the Codable
7881
// conformance at the bottom of this file, and in the `rawIndexableTextContent` method in
@@ -528,6 +531,21 @@ public enum RenderBlockContent: Equatable {
528531
self.items = items
529532
}
530533
}
534+
535+
/// A video with an optional caption.
536+
public struct Video: Codable, Equatable {
537+
/// A reference to the video media that should be rendered in this block.
538+
public let identifier: RenderReferenceIdentifier
539+
540+
/// Any metadata associated with this video, like a caption.
541+
public let metadata: RenderContentMetadata?
542+
543+
/// Create a new video with the given identifier and metadata.
544+
public init(identifier: RenderReferenceIdentifier, metadata: RenderContentMetadata? = nil) {
545+
self.identifier = identifier
546+
self.metadata = metadata
547+
}
548+
}
531549
}
532550

533551
// Writing a manual Codable implementation for tables because the encoding of `extendedData` does
@@ -616,6 +634,7 @@ extension RenderBlockContent: Codable {
616634
case header, rows
617635
case numberOfColumns, columns
618636
case tabs
637+
case identifier
619638
}
620639

621640
public init(from decoder: Decoder) throws {
@@ -682,11 +701,18 @@ extension RenderBlockContent: Codable {
682701
items: container.decode([String].self, forKey: .items)
683702
)
684703
)
704+
case .video:
705+
self = try .video(
706+
Video(
707+
identifier: container.decode(RenderReferenceIdentifier.self, forKey: .identifier),
708+
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
709+
)
710+
)
685711
}
686712
}
687713

688714
private enum BlockType: String, Codable {
689-
case paragraph, aside, codeListing, heading, orderedList, unorderedList, step, endpointExample, dictionaryExample, table, termList, row, small, tabNavigator, links
715+
case paragraph, aside, codeListing, heading, orderedList, unorderedList, step, endpointExample, dictionaryExample, table, termList, row, small, tabNavigator, links, video
690716
}
691717

692718
private var type: BlockType {
@@ -706,6 +732,7 @@ extension RenderBlockContent: Codable {
706732
case .small: return .small
707733
case .tabNavigator: return .tabNavigator
708734
case .links: return .links
735+
case .video: return .video
709736
default: fatalError("unknown RenderBlockContent case in type property")
710737
}
711738
}
@@ -761,6 +788,9 @@ extension RenderBlockContent: Codable {
761788
case .links(let links):
762789
try container.encode(links.style, forKey: .style)
763790
try container.encode(links.items, forKey: .items)
791+
case .video(let video):
792+
try container.encode(video.identifier, forKey: .identifier)
793+
try container.encodeIfPresent(video.metadata, forKey: .metadata)
764794
default:
765795
fatalError("unknown RenderBlockContent case in encode method")
766796
}

Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ struct RenderContentCompiler: MarkupVisitor {
2323
var bundle: DocumentationBundle
2424
var identifier: ResolvedTopicReference
2525
var imageReferences: [String: ImageReference] = [:]
26+
var videoReferences: [String: VideoReference] = [:]
2627
/// Resolved topic references that were seen by the visitor. These should be used to populate the references dictionary.
2728
var collectedTopicReferences = GroupedSequence<String, ResolvedTopicReference> { $0.absoluteString }
2829
var linkReferences: [String: LinkReference] = [:]

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
305305
collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
306306
// Copy all the image references found in the markup container.
307307
imageReferences.merge(contentCompiler.imageReferences) { (_, new) in new }
308+
videoReferences.merge(contentCompiler.videoReferences) { (_, new) in new }
308309
linkReferences.merge(contentCompiler.linkReferences) { (_, new) in new }
309310
return content
310311
}
@@ -316,6 +317,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
316317
collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
317318
// Copy all the image references.
318319
imageReferences.merge(contentCompiler.imageReferences) { (_, new) in new }
320+
videoReferences.merge(contentCompiler.videoReferences) { (_, new) in new }
319321
return content
320322
}
321323

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

14701472
addReferences(imageReferences, to: &node)
1473+
addReferences(videoReferences, to: &node)
14711474
// See Also can contain external links, we need to separately transfer
14721475
// link references from the content compiler
14731476
addReferences(contentCompiler.linkReferences, to: &node)

Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ struct DirectiveIndex {
2121
Small.self,
2222
TabNavigator.self,
2323
Links.self,
24+
ImageMedia.self,
25+
VideoMedia.self,
2426
]
2527

2628
private static let topLevelTutorialDirectives: [AutomaticDirectiveConvertible.Type] = [

Sources/SwiftDocC/Semantics/MarkupReferenceResolver.swift

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,56 @@ struct MarkupReferenceResolver: MarkupRewriter {
168168
} else {
169169
return blockDirective
170170
}
171+
case ImageMedia.directiveName:
172+
guard let imageMedia = ImageMedia(from: blockDirective, source: source, for: bundle, in: context) else {
173+
return blockDirective
174+
}
175+
176+
if !context.resourceExists(with: imageMedia.source, ofType: .image) {
177+
problems.append(
178+
unresolvedResourceProblem(
179+
resource: imageMedia.source,
180+
expectedType: .image,
181+
source: source,
182+
range: imageMedia.originalMarkup.range,
183+
severity: .warning
184+
)
185+
)
186+
}
187+
188+
return blockDirective
189+
case VideoMedia.directiveName:
190+
guard let videoMedia = VideoMedia(from: blockDirective, source: source, for: bundle, in: context) else {
191+
return blockDirective
192+
}
193+
194+
if !context.resourceExists(with: videoMedia.source, ofType: .video) {
195+
problems.append(
196+
unresolvedResourceProblem(
197+
resource: videoMedia.source,
198+
expectedType: .video,
199+
source: source,
200+
range: videoMedia.originalMarkup.range,
201+
severity: .warning
202+
)
203+
)
204+
}
205+
206+
if let posterReference = videoMedia.poster,
207+
!context.resourceExists(with: posterReference, ofType: .image)
208+
{
209+
problems.append(
210+
unresolvedResourceProblem(
211+
resource: posterReference,
212+
expectedType: .image,
213+
source: source,
214+
range: videoMedia.originalMarkup.range,
215+
severity: .warning
216+
)
217+
)
218+
}
219+
220+
return blockDirective
171221
case Comment.directiveName:
172222
return blockDirective
173223
default:

Sources/SwiftDocC/Semantics/Media/ImageMedia.swift

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ public final class ImageMedia: Semantic, Media, AutomaticDirectiveConvertible {
1818
public let originalMarkup: BlockDirective
1919

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

24+
/// An optional caption that should be rendered alongside the image.
25+
@ChildMarkup(numberOfParagraphs: .zeroOrOne)
26+
public private(set) var caption: MarkupContainer
27+
2428
@DirectiveArgumentWrapped(
2529
parseArgument: { bundle, argumentValue in
2630
ResourceReference(bundleIdentifier: bundle.identifier, path: argumentValue)
@@ -31,6 +35,7 @@ public final class ImageMedia: Semantic, Media, AutomaticDirectiveConvertible {
3135
static var keyPaths: [String : AnyKeyPath] = [
3236
"altText" : \ImageMedia._altText,
3337
"source" : \ImageMedia._source,
38+
"caption" : \ImageMedia._caption,
3439
]
3540

3641
/// Creates a new image with the given parameters.
@@ -55,3 +60,25 @@ public final class ImageMedia: Semantic, Media, AutomaticDirectiveConvertible {
5560
return visitor.visitImageMedia(self)
5661
}
5762
}
63+
64+
extension ImageMedia: RenderableDirectiveConvertible {
65+
func render(with contentCompiler: inout RenderContentCompiler) -> [RenderContent] {
66+
var renderedCaption: [RenderInlineContent]?
67+
if let caption = caption.first {
68+
let blockContent = contentCompiler.visit(caption)
69+
if case let .paragraph(paragraph) = blockContent.first as? RenderBlockContent {
70+
renderedCaption = paragraph.inlineContent
71+
}
72+
}
73+
74+
guard let renderedImage = contentCompiler.visitImage(
75+
source: source.path,
76+
altText: altText,
77+
caption: renderedCaption
78+
).first as? RenderInlineContent else {
79+
return []
80+
}
81+
82+
return [RenderBlockContent.paragraph(.init(inlineContent: [renderedImage]))]
83+
}
84+
}

Sources/SwiftDocC/Semantics/Media/VideoMedia.swift

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,14 @@ public final class VideoMedia: Semantic, Media, AutomaticDirectiveConvertible {
2424
)
2525
public private(set) var source: ResourceReference
2626

27+
/// Alternate text describing the video.
28+
@DirectiveArgumentWrapped(name: .custom("alt"))
29+
public private(set) var altText: String? = nil
30+
31+
/// An optional caption that should be rendered alongside the video.
32+
@ChildMarkup(numberOfParagraphs: .zeroOrOne)
33+
public private(set) var caption: MarkupContainer
34+
2735
/// An image to be shown when the video isn't playing.
2836
@DirectiveArgumentWrapped(
2937
parseArgument: { bundle, argumentValue in
@@ -33,8 +41,10 @@ public final class VideoMedia: Semantic, Media, AutomaticDirectiveConvertible {
3341
public private(set) var poster: ResourceReference? = nil
3442

3543
static var keyPaths: [String : AnyKeyPath] = [
36-
"source" : \VideoMedia._source,
37-
"poster" : \VideoMedia._poster,
44+
"source" : \VideoMedia._source,
45+
"poster" : \VideoMedia._poster,
46+
"caption" : \VideoMedia._caption,
47+
"altText" : \VideoMedia._altText,
3848
]
3949

4050
init(originalMarkup: BlockDirective, source: ResourceReference, poster: ResourceReference?) {
@@ -54,3 +64,48 @@ public final class VideoMedia: Semantic, Media, AutomaticDirectiveConvertible {
5464
}
5565
}
5666

67+
extension VideoMedia: RenderableDirectiveConvertible {
68+
func render(with contentCompiler: inout RenderContentCompiler) -> [RenderContent] {
69+
var renderedCaption: [RenderInlineContent]?
70+
if let caption = caption.first {
71+
let blockContent = contentCompiler.visit(caption)
72+
if case let .paragraph(paragraph) = blockContent.first as? RenderBlockContent {
73+
renderedCaption = paragraph.inlineContent
74+
}
75+
}
76+
77+
var posterReferenceIdentifier: RenderReferenceIdentifier?
78+
if let poster = poster {
79+
posterReferenceIdentifier = contentCompiler.resolveImage(source: poster.path)
80+
}
81+
82+
let unescapedVideoSource = source.path.removingPercentEncoding ?? source.path
83+
let videoIdentifier = RenderReferenceIdentifier(unescapedVideoSource)
84+
guard let resolvedVideos = contentCompiler.context.resolveAsset(
85+
named: unescapedVideoSource,
86+
in: contentCompiler.identifier,
87+
withType: .video
88+
) else {
89+
return []
90+
}
91+
92+
contentCompiler.videoReferences[unescapedVideoSource] = VideoReference(
93+
identifier: videoIdentifier,
94+
altText: altText,
95+
videoAsset: resolvedVideos,
96+
poster: posterReferenceIdentifier
97+
)
98+
99+
var metadata: RenderContentMetadata?
100+
if let renderedCaption = renderedCaption {
101+
metadata = RenderContentMetadata(abstract: renderedCaption)
102+
}
103+
104+
let video = RenderBlockContent.Video(
105+
identifier: videoIdentifier,
106+
metadata: metadata
107+
)
108+
109+
return [RenderBlockContent.video(video)]
110+
}
111+
}

0 commit comments

Comments
 (0)