diff --git a/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift b/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift index 620d184122..66d43e1e24 100644 --- a/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift +++ b/Sources/SwiftDocC/Infrastructure/Symbol Graph/SymbolGraphLoader.swift @@ -224,35 +224,37 @@ struct SymbolGraphLoader { for (selector, _) in symbol.mixins { if var symbolAvailability = (symbol.mixins[selector]?["availability"] as? SymbolGraph.Symbol.Availability) { guard !symbolAvailability.availability.isEmpty else { continue } - // For platforms with a fallback option (e.g., Catalyst and iOS), apply the explicit availability annotation of the fallback platform when it is not explicitly available on the primary platform. - DefaultAvailability.fallbackPlatforms.forEach { (fallbackPlatform, inheritedPlatform) in + // For platforms with a fallback option (e.g. Catalyst and iPadOS), + // if the availability is not explicitly available for the platform, + // apply the explicit availability annotation of the fallback platform. + DefaultAvailability.fallbackPlatforms.forEach { (platform, fallback) in guard - var inheritedAvailability = symbolAvailability.availability.first(where: { - $0.matches(inheritedPlatform) + var fallbackAvailability = symbolAvailability.availability.first(where: { + $0.matches(fallback) }), - let fallbackAvailabilityIntroducedVersion = symbolAvailability.availability.first(where: { - $0.matches(fallbackPlatform) + let platformAvailabilityIntroducedVersion = symbolAvailability.availability.first(where: { + $0.matches(platform) })?.introducedVersion, - let defaultAvailabilityIntroducedVersion = defaultAvailabilities.first(where: { $0.platformName == fallbackPlatform })?.introducedVersion + let defaultAvailabilityIntroducedVersion = defaultAvailabilities.first(where: { $0.platformName == platform })?.introducedVersion else { return } // Ensure that the availability version is not overwritten if the symbol has an explicit availability annotation for that platform. - if SymbolGraph.SemanticVersion(string: defaultAvailabilityIntroducedVersion) == fallbackAvailabilityIntroducedVersion { - inheritedAvailability.domain = SymbolGraph.Symbol.Availability.Domain(rawValue: fallbackPlatform.rawValue) + if SymbolGraph.SemanticVersion(string: defaultAvailabilityIntroducedVersion) == platformAvailabilityIntroducedVersion { + fallbackAvailability.domain = SymbolGraph.Symbol.Availability.Domain(rawValue: platform.rawValue) symbolAvailability.availability.removeAll(where: { - $0.matches(fallbackPlatform) + $0.matches(platform) }) - symbolAvailability.availability.append(inheritedAvailability) + symbolAvailability.availability.append(fallbackAvailability) } } // Add fallback availability. - for (fallbackPlatform, inheritedPlatform) in missingFallbackPlatforms { - if !symbolAvailability.contains(fallbackPlatform) { + for (platform, fallback) in missingFallbackPlatforms { + if !symbolAvailability.contains(platform) { for var fallbackAvailability in symbolAvailability.availability { // Add the platform fallback to the availability mixin the platform is inheriting from. // The added availability copies the entire availability information, // including deprecated and obsolete versions. - if fallbackAvailability.matches(inheritedPlatform) { - fallbackAvailability.domain = SymbolGraph.Symbol.Availability.Domain(rawValue: fallbackPlatform.rawValue) + if fallbackAvailability.matches(fallback) { + fallbackAvailability.domain = SymbolGraph.Symbol.Availability.Domain(rawValue: platform.rawValue) symbolAvailability.availability.append(fallbackAvailability) } } diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index 443eaf0b3d..567932c6cf 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -838,16 +838,42 @@ public struct RenderNodeTranslator: SemanticVisitor { } } - if let availability = article.metadata?.availability, !availability.isEmpty { - let renderAvailability = availability.compactMap({ - let currentPlatform = PlatformName(metadataPlatform: $0.platform).flatMap { name in + if let availabilities = article.metadata?.availability, !availabilities.isEmpty { + let platforms = availabilities.map { PlatformName(metadataPlatform: $0.platform) } + // Render availabilities for declared platforms + let renderAvailabilities = zip(availabilities, platforms).compactMap { availability, platform -> AvailabilityRenderItem? in + let currentVersion = platform.flatMap { + context.configuration.externalMetadata.currentPlatforms?[$0.displayName] + } + return .init(availability, current: currentVersion) + } + // Render availabilities for fallback platforms + let fallbackRenderAvailabilities = DefaultAvailability.fallbackPlatforms.compactMap { platform, fallback -> AvailabilityRenderItem? in + // Skip if the platform already has explicit availability, + // or if the fallback platform is not available. + guard !platforms.contains(platform), + let fallbackIndex = platforms.firstIndex(of: fallback) else { + return nil + } + + // Clone the fallback platform's availability with the new platform name. + // The `availabilities` array is mapped to the `platforms` array, + // so the indices of elements across them are guaranteed to be consistent. + let fallbackAvailability = Metadata.Availability(from: availabilities[fallbackIndex].originalMarkup, for: context.inputs)! + fallbackAvailability.platform = Metadata.Availability.Platform(rawValue: platform.rawValue)! + // Use the fallback platform's version to correctly determine beta status + let currentVersion = platforms[fallbackIndex].flatMap { name in context.configuration.externalMetadata.currentPlatforms?[name.displayName] } - return .init($0, current: currentPlatform) - }).sorted(by: AvailabilityRenderOrder.compare) - if !renderAvailability.isEmpty { - node.metadata.platformsVariants = .init(defaultValue: renderAvailability) + return .init(fallbackAvailability, current: currentVersion) + } + + let allRenderAvailabilities = (renderAvailabilities + fallbackRenderAvailabilities) + .sorted(by: AvailabilityRenderOrder.compare) + + if !allRenderAvailabilities.isEmpty { + node.metadata.platformsVariants = .init(defaultValue: allRenderAvailabilities) } } diff --git a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift index 5cacc05eab..512902ac76 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderSectionTranslator/DeclarationsSectionTranslator.swift @@ -200,9 +200,9 @@ struct DeclarationsSectionTranslator: RenderSectionTranslator { func expandPlatformsWithFallbacks(_ platforms: [PlatformName?]) -> [PlatformName?] { guard !platforms.isEmpty else { return platforms } - // Add fallback platforms if their primary platform is present but the fallback is missing - let fallbacks = DefaultAvailability.fallbackPlatforms.compactMap { fallback, primary in - platforms.contains(primary) && !platforms.contains(fallback) ? fallback : nil + // Add fallback platforms if the platform is missing but the fallback is present + let fallbacks = DefaultAvailability.fallbackPlatforms.compactMap { platform, fallback in + platforms.contains(fallback) && !platforms.contains(platform) ? platform : nil } return platforms + fallbacks } diff --git a/Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift b/Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift index f013c1e4f4..165e6eaa34 100644 --- a/Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift +++ b/Tests/SwiftDocCTests/Rendering/PlatformAvailabilityTests.swift @@ -43,12 +43,18 @@ class PlatformAvailabilityTests: XCTestCase { let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article) var translator = RenderNodeTranslator(context: context, identifier: reference) let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode) - let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue) - XCTAssertEqual(availability.count, 1) - let iosAvailability = try XCTUnwrap(availability.first) - XCTAssertEqual(iosAvailability.name, "iOS") + let availabilities = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue) + // iOS introduces iPadOS and Mac Catalyst as fallback platforms + XCTAssertEqual(availabilities.count, 3) + XCTAssertEqual(availabilities.compactMap { $0.name }, ["iOS", "iPadOS", "Mac Catalyst"]) + let iosAvailability = try XCTUnwrap(availabilities.first) XCTAssertEqual(iosAvailability.introduced, "16.0") XCTAssert(iosAvailability.isBeta != true) + // Ensure that the fallback platforms have the same version and beta status as iOS + for availability in availabilities.dropFirst() { + XCTAssertEqual(availability.introduced, iosAvailability.introduced) + XCTAssertEqual(availability.isBeta, iosAvailability.isBeta) + } } /// Ensure that adding `@Available` directives in an extension file overrides the symbol's availability. @@ -81,7 +87,8 @@ class PlatformAvailabilityTests: XCTestCase { var translator = RenderNodeTranslator(context: context, identifier: reference) let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode) let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue) - XCTAssertEqual(availability.count, 3) + // iOS introduces iPadOS and Mac Catalyst as fallback platforms + XCTAssertEqual(availability.count, 5) XCTAssert(availability.contains(where: { item in item.name == "iOS" && item.introduced == "15.0" @@ -173,12 +180,17 @@ class PlatformAvailabilityTests: XCTestCase { let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article) var translator = RenderNodeTranslator(context: context, identifier: reference) let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode) - let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue) - XCTAssertEqual(availability.count, 1) - let iosAvailability = try XCTUnwrap(availability.first) + let availabilities = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue) + // iOS introduces iPadOS and Mac Catalyst as fallback platforms + XCTAssertEqual(availabilities.count, 3) + let iosAvailability = try XCTUnwrap(availabilities.first) XCTAssertEqual(iosAvailability.name, "iOS") XCTAssertEqual(iosAvailability.introduced, "16.0") XCTAssert(iosAvailability.isBeta == true) + // Ensure that fallback platforms are also marked as beta + for availability in availabilities.dropFirst() { + XCTAssert(availability.isBeta == true) + } } func testMultipleBetaPlatformAvailabilityFromArticle() async throws { @@ -197,7 +209,8 @@ class PlatformAvailabilityTests: XCTestCase { var translator = RenderNodeTranslator(context: context, identifier: reference) let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode) let availability = try XCTUnwrap(renderNode.metadata.platformsVariants.defaultValue) - XCTAssertEqual(availability.count, 3) + // iOS introduces iPadOS and Mac Catalyst as fallback platforms + XCTAssertEqual(availability.count, 5) XCTAssert(availability.contains(where: { item in item.name == "iOS" && item.introduced == "15.0"