Skip to content

Commit c9a5c5e

Browse files
add @calltoaction metadata directive to reference downloadable content (#435)
rdar://57847199
1 parent ca6d4d5 commit c9a5c5e

File tree

15 files changed

+532
-7
lines changed

15 files changed

+532
-7
lines changed

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,13 +779,39 @@ public struct RenderNodeTranslator: SemanticVisitor {
779779

780780
return seeAlsoSections
781781
} ?? .init(defaultValue: [])
782+
783+
if let callToAction = article.metadata?.callToAction {
784+
if let url = callToAction.url {
785+
let downloadIdentifier = RenderReferenceIdentifier(url.description)
786+
node.sampleDownload = .init(
787+
action: .reference(
788+
identifier: downloadIdentifier,
789+
isActive: true,
790+
overridingTitle: callToAction.buttonLabel,
791+
overridingTitleInlineContent: nil))
792+
downloadReferences[url.description] = DownloadReference(
793+
identifier: downloadIdentifier,
794+
renderURL: url,
795+
sha512Checksum: nil
796+
)
797+
} else if let fileReference = callToAction.file {
798+
let downloadIdentifier = createAndRegisterRenderReference(forMedia: fileReference, assetContext: .download)
799+
node.sampleDownload = .init(action: .reference(
800+
identifier: downloadIdentifier,
801+
isActive: true,
802+
overridingTitle: callToAction.buttonLabel,
803+
overridingTitleInlineContent: nil
804+
))
805+
}
806+
}
782807

783808
collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
784809
node.references = createTopicRenderReferences()
785810

786811
addReferences(imageReferences, to: &node)
787812
addReferences(videoReferences, to: &node)
788813
addReferences(linkReferences, to: &node)
814+
addReferences(downloadReferences, to: &node)
789815
// See Also can contain external links, we need to separately transfer
790816
// link references from the content compiler
791817
addReferences(contentCompiler.linkReferences, to: &node)

Sources/SwiftDocC/Model/Rendering/Tutorial/References/DownloadReference.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,15 @@ public struct DownloadReference: RenderReference, URLReference {
2828
public var url: URL
2929

3030
/// The SHA512 hash value for the resource.
31-
public var sha512Checksum: String
31+
public var sha512Checksum: String?
3232

3333
/// Creates a new reference to a downloadable resource.
3434
///
3535
/// - Parameters:
3636
/// - identifier: An identifier for the resource's reference.
3737
/// - url: The path to the resource.
3838
/// - sha512Checksum: The SHA512 hash value for the resource.
39-
public init(identifier: RenderReferenceIdentifier, renderURL url: URL, sha512Checksum: String) {
39+
public init(identifier: RenderReferenceIdentifier, renderURL url: URL, sha512Checksum: String?) {
4040
self.identifier = identifier
4141
self.url = url
4242
self.sha512Checksum = sha512Checksum
@@ -53,7 +53,7 @@ public struct DownloadReference: RenderReference, URLReference {
5353
var container = encoder.container(keyedBy: CodingKeys.self)
5454
try container.encode(type.rawValue, forKey: .type)
5555
try container.encode(identifier, forKey: .identifier)
56-
try container.encode(sha512Checksum, forKey: .sha512Checksum)
56+
try container.encodeIfPresent(sha512Checksum, forKey: .sha512Checksum)
5757

5858
// Render URL
5959
try container.encode(renderURL(for: url), forKey: .url)
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2022 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import Markdown
13+
14+
/// A directive that adds a prominent button or link to a page's header.
15+
///
16+
/// A "Call to Action" has two main components: a link or file path, and the link text to display.
17+
///
18+
/// The link path can be specified in one of two ways:
19+
/// - The `url` parameter specifies a URL that will be used verbatim. Use this when you're linking
20+
/// to an external page or externally-hosted file.
21+
/// - The `path` parameter specifies the path to a file hosted within your documentation catalog.
22+
/// Use this if you're linking to a downloadable file that you're managing alongside your
23+
/// articles and tutorials.
24+
///
25+
/// The link text can also be specified in one of two ways:
26+
/// - The `purpose` parameter can be used to use a default button label. There are two valid values:
27+
/// - `download` indicates that the link is to a downloadable file. The button will be labeled "Download".
28+
/// - `link` indicates that the link is to an external webpage. The button will be labeled "Visit".
29+
/// - The `label` parameter specifies the literal text to use as the button label.
30+
///
31+
/// `@CallToAction` requires one of `url` or `path`, and one of `purpose` or `label`. Specifying both
32+
/// `purpose` and `label` is allowed, but the `label` will override the default label provided by
33+
/// `purpose`.
34+
///
35+
/// This directive is only valid within a ``Metadata`` directive:
36+
///
37+
/// ```markdown
38+
/// @Metadata {
39+
/// @CallToAction(url: "https://example.com/sample.zip", purpose: download)
40+
/// }
41+
/// ```
42+
public final class CallToAction: Semantic, AutomaticDirectiveConvertible {
43+
/// The kind of action the link is referencing.
44+
public enum Purpose: String, CaseIterable, DirectiveArgumentValueConvertible {
45+
/// References a link to download an associated asset, like a sample project.
46+
case download
47+
48+
/// References a link to view external content, like a source code repository.
49+
case link
50+
}
51+
52+
/// The location of the associated link, as a fixed URL.
53+
@DirectiveArgumentWrapped
54+
public var url: URL? = nil
55+
56+
/// The location of the associated link, as a reference to a file in this documentation bundle.
57+
@DirectiveArgumentWrapped(
58+
parseArgument: { bundle, argumentValue in
59+
ResourceReference(bundleIdentifier: bundle.identifier, path: argumentValue)
60+
}
61+
)
62+
public var file: ResourceReference? = nil
63+
64+
/// The purpose of this Call to Action, which provides a default button label.
65+
@DirectiveArgumentWrapped
66+
public var purpose: Purpose? = nil
67+
68+
/// Text to use as the button label, which may override ``purpose-swift.property``.
69+
@DirectiveArgumentWrapped
70+
public var label: String? = nil
71+
72+
static var keyPaths: [String : AnyKeyPath] = [
73+
"url" : \CallToAction._url,
74+
"file" : \CallToAction._file,
75+
"purpose" : \CallToAction._purpose,
76+
"label" : \CallToAction._label,
77+
]
78+
79+
/// The computed label for this Call to Action, whether provided directly via ``label`` or
80+
/// indirectly via ``purpose-swift.property``.
81+
public var buttonLabel: String {
82+
if let label = label {
83+
return label
84+
} else if let purpose = purpose {
85+
return purpose.defaultLabel
86+
} else {
87+
// The `validate()` method ensures that this type should never be constructed without
88+
// one of the above.
89+
fatalError("A valid CallToAction should have either a purpose or label")
90+
}
91+
}
92+
93+
func validate(
94+
source: URL?,
95+
for bundle: DocumentationBundle,
96+
in context: DocumentationContext,
97+
problems: inout [Problem]
98+
) -> Bool {
99+
var isValid = true
100+
101+
if self.url == nil && self.file == nil {
102+
problems.append(.init(diagnostic: .init(
103+
source: source,
104+
severity: .warning,
105+
range: originalMarkup.range,
106+
identifier: "org.swift.docc.\(CallToAction.self).missingLink",
107+
summary: "\(CallToAction.directiveName.singleQuoted) directive requires `url` or `file` argument",
108+
explanation: "The Call to Action requires a link to direct the user to."
109+
)))
110+
111+
isValid = false
112+
} else if self.url != nil && self.file != nil {
113+
problems.append(.init(diagnostic: .init(
114+
source: source,
115+
severity: .warning,
116+
range: originalMarkup.range,
117+
identifier: "org.swift.docc.\(CallToAction.self).tooManyLinks",
118+
summary: "\(CallToAction.directiveName.singleQuoted) directive requires only one of `url` or `file`",
119+
explanation: "Both the `url` and `file` arguments specify the link in the heading; specifying both of them creates ambiguity in where the call should link."
120+
)))
121+
122+
isValid = false
123+
}
124+
125+
if self.purpose == nil && self.label == nil {
126+
problems.append(.init(diagnostic: .init(
127+
source: source,
128+
severity: .warning,
129+
range: originalMarkup.range,
130+
identifier: "org.swift.docc.\(CallToAction.self).missingLabel",
131+
summary: "\(CallToAction.directiveName.singleQuoted) directive requires `purpose` or `label` argument",
132+
explanation: "Without a `purpose` or `label`, the Call to Action has no label to apply to the link."
133+
)))
134+
135+
isValid = false
136+
}
137+
138+
if let file = self.file {
139+
if context.resolveAsset(named: file.url.lastPathComponent, in: bundle.rootReference) == nil {
140+
problems.append(.init(
141+
diagnostic: Diagnostic(
142+
source: url,
143+
severity: .warning,
144+
range: originalMarkup.range,
145+
identifier: "org.swift.docc.Project.ProjectFilesNotFound",
146+
summary: "\(file.path) file reference not found in \(CallToAction.directiveName.singleQuoted) directive"),
147+
possibleSolutions: [
148+
Solution(summary: "Copy the referenced file into the documentation bundle directory", replacements: [])
149+
]
150+
))
151+
} else {
152+
self.file = ResourceReference(bundleIdentifier: file.bundleIdentifier, path: file.url.lastPathComponent)
153+
}
154+
}
155+
156+
return isValid
157+
}
158+
159+
public let originalMarkup: Markdown.BlockDirective
160+
161+
@available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.")
162+
init(originalMarkup: Markdown.BlockDirective) {
163+
self.originalMarkup = originalMarkup
164+
}
165+
}
166+
167+
extension CallToAction.Purpose {
168+
/// The label that will be applied to a Call to Action with this purpose if it doesn't provide
169+
/// a separate label.
170+
public var defaultLabel: String {
171+
switch self {
172+
case .download:
173+
return "Download"
174+
case .link:
175+
return "Visit"
176+
}
177+
}
178+
}

Sources/SwiftDocC/Semantics/Metadata/Metadata.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ import Markdown
2121
///
2222
/// - ``DocumentationExtension``
2323
/// - ``TechnologyRoot``
24+
/// - ``DisplayName``
25+
/// - ``PageImage``
26+
/// - ``CallToAction``
2427
public final class Metadata: Semantic, AutomaticDirectiveConvertible {
2528
public let originalMarkup: BlockDirective
2629

@@ -42,13 +45,17 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {
4245

4346
@ChildDirective(requirements: .zeroOrMore)
4447
var customMetadata: [CustomMetadata]
48+
49+
@ChildDirective
50+
var callToAction: CallToAction? = nil
4551

4652
static var keyPaths: [String : AnyKeyPath] = [
4753
"documentationOptions" : \Metadata._documentationOptions,
4854
"technologyRoot" : \Metadata._technologyRoot,
4955
"displayName" : \Metadata._displayName,
5056
"pageImages" : \Metadata._pageImages,
5157
"customMetadata" : \Metadata._customMetadata,
58+
"callToAction" : \Metadata._callToAction,
5259
]
5360

5461
/// Creates a metadata object with a given markup, documentation extension, and technology root.

Sources/SwiftDocC/Semantics/ReferenceResolver.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,12 @@ struct ReferenceResolver: SemanticVisitor {
344344
let newDeprecationSummary = article.deprecationSummary.flatMap {
345345
visitMarkupContainer($0) as? MarkupContainer
346346
}
347+
// If there's a call to action with a local-file reference, change its context to `download`
348+
if let downloadFile = article.metadata?.callToAction?.file,
349+
var resolvedDownload = context.resolveAsset(named: downloadFile.path, in: bundle.rootReference) {
350+
resolvedDownload.context = .download
351+
context.updateAsset(named: downloadFile.path, asset: resolvedDownload, in: bundle.rootReference)
352+
}
347353

348354
return Article(
349355
title: article.title,

Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1808,8 +1808,7 @@
18081808
"required": [
18091809
"type",
18101810
"identifier",
1811-
"url",
1812-
"checksum"
1811+
"url"
18131812
],
18141813
"properties": {
18151814
"type": {

Tests/SwiftDocCTests/Rendering/SampleDownloadTests.swift

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/*
22
This source file is part of the Swift.org open source project
33

4-
Copyright (c) 2021 Apple Inc. and the Swift project authors
4+
Copyright (c) 2021-2022 Apple Inc. and the Swift project authors
55
Licensed under Apache License v2.0 with Runtime Library Exception
66

77
See https://swift.org/LICENSE.txt for license information
@@ -73,5 +73,93 @@ class SampleDownloadTests: XCTestCase {
7373
let text = contentParagraph.inlineContent.rawIndexableTextContent(references: symbol.references)
7474
XCTAssertEqual(text, "You can experiment with the code. Just use WiFi Access on your Mac to download WiFi access sample code.")
7575
}
76+
77+
func testParseSampleDownload() throws {
78+
let (bundle, context) = try testBundleAndContext(named: "SampleBundle")
79+
let reference = ResolvedTopicReference(
80+
bundleIdentifier: bundle.identifier,
81+
path: "/documentation/SampleBundle/MySample",
82+
sourceLanguage: .swift
83+
)
84+
let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article)
85+
var translator = RenderNodeTranslator(
86+
context: context,
87+
bundle: bundle,
88+
identifier: reference,
89+
source: nil
90+
)
91+
let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode)
92+
let sampleCodeDownload = try XCTUnwrap(renderNode.sampleDownload)
93+
guard case .reference(identifier: let ident, isActive: true, overridingTitle: "Download", overridingTitleInlineContent: nil) = sampleCodeDownload.action else {
94+
XCTFail("Unexpected action in callToAction")
95+
return
96+
}
97+
XCTAssertEqual(ident.identifier, "https://example.com/sample.zip")
98+
}
99+
100+
func testParseSampleLocalDownload() throws {
101+
let (bundle, context) = try testBundleAndContext(named: "SampleBundle")
102+
let reference = ResolvedTopicReference(
103+
bundleIdentifier: bundle.identifier,
104+
path: "/documentation/SampleBundle/MyLocalSample",
105+
sourceLanguage: .swift
106+
)
107+
let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article)
108+
var translator = RenderNodeTranslator(
109+
context: context,
110+
bundle: bundle,
111+
identifier: reference,
112+
source: nil
113+
)
114+
let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode)
115+
let sampleCodeDownload = try XCTUnwrap(renderNode.sampleDownload)
116+
guard case .reference(identifier: let ident, isActive: true, overridingTitle: "Download", overridingTitleInlineContent: nil) = sampleCodeDownload.action else {
117+
XCTFail("Unexpected action in callToAction")
118+
return
119+
}
120+
XCTAssertEqual(ident.identifier, "plus.svg")
121+
}
122+
123+
func testSampleDownloadRoundtrip() throws {
124+
let (bundle, context) = try testBundleAndContext(named: "SampleBundle")
125+
let reference = ResolvedTopicReference(
126+
bundleIdentifier: bundle.identifier,
127+
path: "/documentation/SampleBundle/MySample",
128+
sourceLanguage: .swift
129+
)
130+
let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article)
131+
var translator = RenderNodeTranslator(
132+
context: context,
133+
bundle: bundle,
134+
identifier: reference,
135+
source: nil
136+
)
137+
let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode)
138+
139+
let encoder = JSONEncoder()
140+
let decoder = JSONDecoder()
141+
142+
let encodedNode = try encoder.encode(renderNode)
143+
let decodedNode = try decoder.decode(RenderNode.self, from: encodedNode)
144+
145+
guard case let .reference(
146+
identifier: origIdent,
147+
isActive: _,
148+
overridingTitle: _,
149+
overridingTitleInlineContent: _
150+
) = renderNode.sampleDownload?.action,
151+
case let .reference(
152+
identifier: decodedIdent,
153+
isActive: _,
154+
overridingTitle: _,
155+
overridingTitleInlineContent: _
156+
) = decodedNode.sampleDownload?.action
157+
else {
158+
XCTFail("RenderNode should have callToAction both before and after roundtrip")
159+
return
160+
}
161+
162+
XCTAssertEqual(origIdent, decodedIdent)
163+
}
76164

77165
}

0 commit comments

Comments
 (0)