Skip to content

Commit 9c28bc4

Browse files
add @Available directive for setting platform availability (#440)
rdar://57847232
1 parent f1fc31f commit 9c28bc4

File tree

14 files changed

+981
-2
lines changed

14 files changed

+981
-2
lines changed

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,19 @@ public struct RenderNodeTranslator: SemanticVisitor {
804804
))
805805
}
806806
}
807+
808+
if let availability = article.metadata?.availability, !availability.isEmpty {
809+
let renderAvailability = availability.compactMap({
810+
let currentPlatform = PlatformName(metadataPlatform: $0.platform).flatMap { name in
811+
context.externalMetadata.currentPlatforms?[name.displayName]
812+
}
813+
return .init($0, current: currentPlatform)
814+
}).sorted(by: AvailabilityRenderOrder.compare)
815+
816+
if !renderAvailability.isEmpty {
817+
node.metadata.platformsVariants = .init(defaultValue: renderAvailability)
818+
}
819+
}
807820

808821
collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
809822
node.references = createTopicRenderReferences()
@@ -1193,6 +1206,19 @@ public struct RenderNodeTranslator: SemanticVisitor {
11931206
.filter({ !($0.unconditionallyUnavailable == true) })
11941207
.sorted(by: AvailabilityRenderOrder.compare)
11951208
)
1209+
1210+
if let availability = documentationNode.metadata?.availability, !availability.isEmpty {
1211+
let renderAvailability = availability.compactMap({
1212+
let currentPlatform = PlatformName(metadataPlatform: $0.platform).flatMap { name in
1213+
context.externalMetadata.currentPlatforms?[name.displayName]
1214+
}
1215+
return .init($0, current: currentPlatform)
1216+
}).sorted(by: AvailabilityRenderOrder.compare)
1217+
1218+
if !renderAvailability.isEmpty {
1219+
node.metadata.platformsVariants.defaultValue = renderAvailability
1220+
}
1221+
}
11961222

11971223
node.metadata.requiredVariants = VariantCollection<Bool>(from: symbol.isRequiredVariants) ?? .init(defaultValue: false)
11981224
node.metadata.role = contentRenderer.role(for: documentationNode.kind).rawValue

Sources/SwiftDocC/Model/Rendering/Symbol/AvailabilityRenderMetadataItem.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,18 @@ public struct AvailabilityRenderItem: Codable, Hashable, Equatable {
139139
isBeta = false
140140
}
141141
}
142+
143+
init?(_ availability: Metadata.Availability, current: PlatformVersion?) {
144+
if availability.introduced == nil {
145+
// FIXME: Deprecated/Beta markings need platform versions to display properly in Swift-DocC-Render (rdar://56897597)
146+
// Fill in the appropriate values here when that's fixed (https://github.com/apple/swift-docc/issues/441)
147+
return nil
148+
}
149+
150+
let platformName = PlatformName(metadataPlatform: availability.platform)
151+
name = platformName?.displayName
152+
introduced = availability.introduced
153+
}
142154

143155
/// Creates a new item with the given platform name and version string.
144156
/// - Parameters:
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
extension Metadata {
15+
/// A directive that sets the platform availability information for a documentation page.
16+
///
17+
/// `@Available` is analagous to the `@available` attribute in Swift: It allows you to specify a
18+
/// platform version that the page relates to. To specify a platform and version, list the platform
19+
/// name and use the `introduced` argument:
20+
///
21+
/// ```markdown
22+
/// @Available(macOS, introduced: "12.0")
23+
/// ```
24+
///
25+
/// The available platforms are `macOS`, `iOS`, `watchOS`, and `tvOS`.
26+
///
27+
/// This directive is available on both articles and documentation extension files. In extension
28+
/// files, the information overrides any information from the symbol itself.
29+
///
30+
/// This directive is only valid within a ``Metadata`` directive:
31+
///
32+
/// ```markdown
33+
/// @Metadata {
34+
/// @Available(macOS, introduced: "12.0")
35+
/// @Available(iOS, introduced: "15.0")
36+
/// }
37+
/// ```
38+
public final class Availability: Semantic, AutomaticDirectiveConvertible {
39+
static public let directiveName: String = "Available"
40+
41+
public enum Platform: String, RawRepresentable, CaseIterable, DirectiveArgumentValueConvertible {
42+
// FIXME: re-add `case any = "*"` when `isBeta` and `isDeprecated` are implemented
43+
// cf. https://github.com/apple/swift-docc/issues/441
44+
case macOS, iOS, watchOS, tvOS
45+
46+
public init?(rawValue: String) {
47+
for platform in Self.allCases {
48+
if platform.rawValue.lowercased() == rawValue.lowercased() {
49+
self = platform
50+
return
51+
}
52+
}
53+
return nil
54+
}
55+
}
56+
57+
/// The platform that this argument's information applies to.
58+
@DirectiveArgumentWrapped(name: .unnamed)
59+
public var platform: Platform
60+
61+
/// The platform version that this page applies to.
62+
@DirectiveArgumentWrapped
63+
public var introduced: String
64+
65+
// FIXME: `isBeta` and `isDeprecated` properties/arguments
66+
// cf. https://github.com/apple/swift-docc/issues/441
67+
68+
static var keyPaths: [String : AnyKeyPath] = [
69+
"platform" : \Availability._platform,
70+
"introduced" : \Availability._introduced,
71+
]
72+
73+
public let originalMarkup: Markdown.BlockDirective
74+
75+
@available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.")
76+
init(originalMarkup: Markdown.BlockDirective) {
77+
self.originalMarkup = originalMarkup
78+
}
79+
}
80+
}

Sources/SwiftDocC/Semantics/Metadata/Metadata.swift

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import Markdown
2424
/// - ``DisplayName``
2525
/// - ``PageImage``
2626
/// - ``CallToAction``
27+
/// - ``Availability``
2728
public final class Metadata: Semantic, AutomaticDirectiveConvertible {
2829
public let originalMarkup: BlockDirective
2930

@@ -48,6 +49,9 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {
4849

4950
@ChildDirective
5051
var callToAction: CallToAction? = nil
52+
53+
@ChildDirective(requirements: .zeroOrMore)
54+
var availability: [Availability]
5155

5256
static var keyPaths: [String : AnyKeyPath] = [
5357
"documentationOptions" : \Metadata._documentationOptions,
@@ -56,6 +60,7 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {
5660
"pageImages" : \Metadata._pageImages,
5761
"customMetadata" : \Metadata._customMetadata,
5862
"callToAction" : \Metadata._callToAction,
63+
"availability" : \Metadata._availability,
5964
]
6065

6166
/// Creates a metadata object with a given markup, documentation extension, and technology root.
@@ -78,7 +83,7 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {
7883

7984
func validate(source: URL?, for bundle: DocumentationBundle, in context: DocumentationContext, problems: inout [Problem]) -> Bool {
8085
// Check that something is configured in the metadata block
81-
if documentationOptions == nil && technologyRoot == nil && displayName == nil && pageImages.isEmpty && customMetadata.isEmpty {
86+
if documentationOptions == nil && technologyRoot == nil && displayName == nil && pageImages.isEmpty && customMetadata.isEmpty && callToAction == nil && availability.isEmpty {
8287
let diagnostic = Diagnostic(
8388
source: source,
8489
severity: .information,
@@ -132,6 +137,44 @@ public final class Metadata: Semantic, AutomaticDirectiveConvertible {
132137
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [solution]))
133138
}
134139
}
140+
141+
let categorizedAvailability = Dictionary(grouping: availability, by: \.platform)
142+
143+
for availabilityAttrs in categorizedAvailability.values {
144+
guard availabilityAttrs.count > 1 else {
145+
continue
146+
}
147+
148+
let duplicateIntroduced = availabilityAttrs.filter({ $0.introduced != nil })
149+
if duplicateIntroduced.count > 1 {
150+
for avail in duplicateIntroduced {
151+
let diagnostic = Diagnostic(
152+
source: avail.originalMarkup.nameLocation?.source,
153+
severity: .warning,
154+
range: avail.originalMarkup.range,
155+
identifier: "org.swift.docc.\(Metadata.Availability.self).DuplicateIntroduced",
156+
summary: "Duplicate \(Metadata.Availability.directiveName.singleQuoted) directive with 'introduced' argument",
157+
explanation: """
158+
A documentation page can only contain a single 'introduced' version for each platform.
159+
"""
160+
)
161+
162+
guard let range = avail.originalMarkup.range else {
163+
problems.append(Problem(diagnostic: diagnostic))
164+
continue
165+
}
166+
167+
let solution = Solution(
168+
summary: "Remove extraneous \(Metadata.Availability.directiveName.singleQuoted) directive",
169+
replacements: [
170+
Replacement(range: range, replacement: "")
171+
]
172+
)
173+
174+
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [solution]))
175+
}
176+
}
177+
}
135178

136179
return true
137180
}

Sources/SwiftDocC/Semantics/Symbol/PlatformName.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,14 @@ public struct PlatformName: Codable, Hashable, Equatable {
101101
}
102102
self = knowDomain
103103
}
104+
105+
/// Creates a new platform name from the given metadata availability attribute platform.
106+
///
107+
/// Returns `nil` if the given platform was ``Metadata/Availability/Platform-swift.enum/any``.
108+
init?(metadataPlatform platform: Metadata.Availability.Platform) {
109+
// Note: This is still an optional initializer to prevent source breakage when
110+
// `Availability.Platform` re-introduces the `.any` case
111+
// cf. https://github.com/apple/swift-docc/issues/441
112+
self = .init(operatingSystemName: platform.rawValue)
113+
}
104114
}

Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,79 @@ class PlatformAvailabilityTests: XCTestCase {
3131
// The "iOS" platform in the fixture is unconditionally unavailable
3232
XCTAssertEqual(true, platforms.first { $0.name == "iOS" }?.unconditionallyUnavailable)
3333
}
34+
35+
/// Ensure that adding `@Available` directives in an article causes the final RenderNode to contain the appropriate availability data.
36+
func testPlatformAvailabilityFromArticle() throws {
37+
let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle")
38+
let reference = ResolvedTopicReference(
39+
bundleIdentifier: bundle.identifier,
40+
path: "/documentation/AvailableArticle",
41+
sourceLanguage: .swift
42+
)
43+
let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article)
44+
var translator = RenderNodeTranslator(
45+
context: context,
46+
bundle: bundle,
47+
identifier: reference,
48+
source: nil
49+
)
50+
let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode)
51+
let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue)
52+
XCTAssertEqual(availability.count, 1)
53+
let iosAvailability = try XCTUnwrap(availability.first)
54+
XCTAssertEqual(iosAvailability.name, "iOS")
55+
XCTAssertEqual(iosAvailability.introduced, "16.0")
56+
}
57+
58+
/// Ensure that adding `@Available` directives in an extension file overrides the symbol's availability.
59+
func testPlatformAvailabilityFromExtension() throws {
60+
let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle")
61+
let reference = ResolvedTopicReference(
62+
bundleIdentifier: bundle.identifier,
63+
path: "/documentation/MyKit/MyClass",
64+
sourceLanguage: .swift
65+
)
66+
let symbol = try XCTUnwrap(context.entity(with: reference).semantic as? Symbol)
67+
var translator = RenderNodeTranslator(
68+
context: context,
69+
bundle: bundle,
70+
identifier: reference,
71+
source: nil
72+
)
73+
let renderNode = try XCTUnwrap(translator.visitSymbol(symbol) as? RenderNode)
74+
let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue)
75+
XCTAssertEqual(availability.count, 1)
76+
let iosAvailability = try XCTUnwrap(availability.first)
77+
XCTAssertEqual(iosAvailability.name, "iOS")
78+
XCTAssertEqual(iosAvailability.introduced, "16.0")
79+
}
80+
81+
func testMultiplePlatformAvailabilityFromArticle() throws {
82+
let (bundle, context) = try testBundleAndContext(named: "AvailabilityBundle")
83+
let reference = ResolvedTopicReference(
84+
bundleIdentifier: bundle.identifier,
85+
path: "/documentation/AvailabilityBundle/ComplexAvailable",
86+
sourceLanguage: .swift
87+
)
88+
let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article)
89+
var translator = RenderNodeTranslator(
90+
context: context,
91+
bundle: bundle,
92+
identifier: reference,
93+
source: nil
94+
)
95+
let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode)
96+
let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue)
97+
XCTAssertEqual(availability.count, 3)
98+
99+
XCTAssert(availability.contains(where: { item in
100+
item.name == "iOS" && item.introduced == "15.0"
101+
}))
102+
XCTAssert(availability.contains(where: { item in
103+
item.name == "macOS" && item.introduced == "12.0"
104+
}))
105+
XCTAssert(availability.contains(where: { item in
106+
item.name == "watchOS" && item.introduced == "7.0"
107+
}))
108+
}
34109
}

Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ class DirectiveIndexTests: XCTestCase {
2121
"AutomaticArticleSubheading",
2222
"AutomaticSeeAlso",
2323
"AutomaticTitleHeading",
24+
"Available",
2425
"CallToAction",
2526
"Chapter",
2627
"Choice",

Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveMirrorTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class DirectiveMirrorTests: XCTestCase {
3939
XCTAssertFalse(reflectedDirective.allowsMarkup)
4040
XCTAssert(reflectedDirective.arguments.isEmpty)
4141

42-
XCTAssertEqual(reflectedDirective.childDirectives.count, 6)
42+
XCTAssertEqual(reflectedDirective.childDirectives.count, 7)
4343

4444
XCTAssertEqual(
4545
reflectedDirective.childDirectives["DocumentationExtension"]?.propertyLabel,

0 commit comments

Comments
 (0)