From 898459fbcdf1676e033bfa6e6b3b7764149320da Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:14:02 +0100 Subject: [PATCH 1/7] Propagate beta attribute for external entities to the navigator The `isBeta` attribute is part of `TopicRenderReference` [1], and is already computed based on the platforms [2][3] (though the platform information itself is dropped). This changes propagates the property to `ExternalRenderNode` and `ExternalRenderNodeMetadataRepresentation` so that we can ultimately store it as part of the navigator `RenderIndex`. [4] Fixes rdar://155521394. [1]: https://github.com/swiftlang/swift-docc/blob/f968935b770b0011d7aa28f59eda22a8407282b7/Sources/SwiftDocC/Model/Rendering/References/TopicRenderReference.swift#L82-L85 [2]: https://github.com/swiftlang/swift-docc/blob/f968935b770b0011d7aa28f59eda22a8407282b7/Sources/SwiftDocC/Infrastructure/External%20Data/OutOfProcessReferenceResolver.swift#L158 [3]: https://github.com/swiftlang/swift-docc/blob/f968935b770b0011d7aa28f59eda22a8407282b7/Sources/SwiftDocC/Infrastructure/External%20Data/OutOfProcessReferenceResolver.swift#L592-L598 [4]: https://github.com/swiftlang/swift-docc/blob/f968935b770b0011d7aa28f59eda22a8407282b7/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift#L251 --- .../LinkResolver+NavigatorIndex.swift | 11 ++- .../Indexing/ExternalRenderNodeTests.swift | 72 +++++++++++++++++-- .../TestExternalReferenceResolvers.swift | 2 + 3 files changed, 77 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift index a0875fbde..c1163f62e 100644 --- a/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift +++ b/Sources/SwiftDocC/Infrastructure/Link Resolution/LinkResolver+NavigatorIndex.swift @@ -77,6 +77,13 @@ package struct ExternalRenderNode { RenderNode.Variant(traits: [.interfaceLanguage($0.id)], paths: [externalEntity.topicRenderReference.url]) } } + + /// A value that indicates whether this symbol is built for a beta platform + /// + /// This value is `false` if the referenced page is not a symbol. + var isBeta: Bool { + externalEntity.topicRenderReference.isBeta + } } /// A language specific representation of an external render node value for building a navigator index. @@ -110,7 +117,8 @@ struct NavigatorExternalRenderNode: NavigatorIndexableRenderNodeRepresentation { externalID: renderNode.externalIdentifier.identifier, role: renderNode.role, symbolKind: renderNode.symbolKind?.identifier, - images: renderNode.images + images: renderNode.images, + isBeta: renderNode.isBeta ) } } @@ -123,6 +131,7 @@ struct ExternalRenderNodeMetadataRepresentation: NavigatorIndexableRenderMetadat var role: String? var symbolKind: String? var images: [TopicImage] + var isBeta: Bool // Values that we have insufficient information to derive. // These are needed to conform to the navigator indexable metadata protocol. diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index 0f220d928..9a9e5bc1e 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -21,7 +21,8 @@ class ExternalRenderNodeTests: XCTestCase { referencePath: "/path/to/external/swiftArticle", title: "SwiftArticle", kind: .article, - language: .swift + language: .swift, + platforms: [.init(name: "iOS", introduced: nil, isBeta: false)] ) ) externalResolver.entitiesToReturn["/path/to/external/objCArticle"] = .success( @@ -29,7 +30,8 @@ class ExternalRenderNodeTests: XCTestCase { referencePath: "/path/to/external/objCArticle", title: "ObjCArticle", kind: .article, - language: .objectiveC + language: .objectiveC, + platforms: [.init(name: "macOS", introduced: nil, isBeta: true)] ) ) externalResolver.entitiesToReturn["/path/to/external/swiftSymbol"] = .success( @@ -37,7 +39,8 @@ class ExternalRenderNodeTests: XCTestCase { referencePath: "/path/to/external/swiftSymbol", title: "SwiftSymbol", kind: .class, - language: .swift + language: .swift, + platforms: [.init(name: "iOS", introduced: nil, isBeta: true)] ) ) externalResolver.entitiesToReturn["/path/to/external/objCSymbol"] = .success( @@ -45,7 +48,8 @@ class ExternalRenderNodeTests: XCTestCase { referencePath: "/path/to/external/objCSymbol", title: "ObjCSymbol", kind: .function, - language: .objectiveC + language: .objectiveC, + platforms: [.init(name: "macOS", introduced: nil, isBeta: false)] ) ) return externalResolver @@ -89,24 +93,28 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(externalRenderNodes[0].symbolKind, nil) XCTAssertEqual(externalRenderNodes[0].role, "article") XCTAssertEqual(externalRenderNodes[0].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCArticle") - + XCTAssertTrue(externalRenderNodes[0].isBeta) + XCTAssertEqual(externalRenderNodes[1].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/objCSymbol") XCTAssertEqual(externalRenderNodes[1].kind, .symbol) XCTAssertEqual(externalRenderNodes[1].symbolKind, nil) XCTAssertEqual(externalRenderNodes[1].role, "symbol") XCTAssertEqual(externalRenderNodes[1].externalIdentifier.identifier, "doc://com.test.external/path/to/external/objCSymbol") + XCTAssertFalse(externalRenderNodes[1].isBeta) XCTAssertEqual(externalRenderNodes[2].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftArticle") XCTAssertEqual(externalRenderNodes[2].kind, .article) XCTAssertEqual(externalRenderNodes[2].symbolKind, nil) XCTAssertEqual(externalRenderNodes[2].role, "article") XCTAssertEqual(externalRenderNodes[2].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftArticle") + XCTAssertFalse(externalRenderNodes[2].isBeta) XCTAssertEqual(externalRenderNodes[3].identifier.absoluteString, "doc://org.swift.MixedLanguageFramework/example/path/to/external/swiftSymbol") XCTAssertEqual(externalRenderNodes[3].kind, .symbol) XCTAssertEqual(externalRenderNodes[3].symbolKind, nil) XCTAssertEqual(externalRenderNodes[3].role, "symbol") XCTAssertEqual(externalRenderNodes[3].externalIdentifier.identifier, "doc://com.test.external/path/to/external/swiftSymbol") + XCTAssertTrue(externalRenderNodes[3].isBeta) } func testExternalRenderNodeVariantRepresentation() throws { @@ -146,14 +154,16 @@ class ExternalRenderNodeTests: XCTestCase { ) XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.title, swiftTitle) XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.navigatorTitle, navigatorTitle) - + XCTAssertFalse(swiftNavigatorExternalRenderNode.metadata.isBeta) + let objcNavigatorExternalRenderNode = try XCTUnwrap( NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage("objc")) ) XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, occTitle) XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.navigatorTitle, occNavigatorTitle) + XCTAssertFalse(objcNavigatorExternalRenderNode.metadata.isBeta) } - + func testNavigatorWithExternalNodes() async throws { let externalResolver = generateExternalResolver() let (_, bundle, context) = try await testBundleAndContext( @@ -269,4 +279,52 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) } + + func testExternalRenderNodeVariantRepresentationWhenIsBeta() throws { + let renderReferenceIdentifier = RenderReferenceIdentifier(forExternalLink: "doc://com.test.external/path/to/external/symbol") + + // Variants for the title + let swiftTitle = "Swift Symbol" + let occTitle = "Occ Symbol" + + // Variants for the navigator title + let navigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "symbol", kind: .identifier)] + let occNavigatorTitle: [DeclarationRenderSection.Token] = [.init(text: "occ_symbol", kind: .identifier)] + + // Variants for the fragments + let fragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "symbol", kind: .identifier)] + let occFragments: [DeclarationRenderSection.Token] = [.init(text: "func", kind: .keyword), .init(text: "occ_symbol", kind: .identifier)] + + let externalEntity = LinkResolver.ExternalEntity( + topicRenderReference: .init( + identifier: renderReferenceIdentifier, + titleVariants: .init(defaultValue: swiftTitle, objectiveCValue: occTitle), + abstractVariants: .init(defaultValue: []), + url: "/example/path/to/external/symbol", + kind: .symbol, + fragmentsVariants: .init(defaultValue: fragments, objectiveCValue: occFragments), + navigatorTitleVariants: .init(defaultValue: navigatorTitle, objectiveCValue: occNavigatorTitle), + isBeta: true + ), + renderReferenceDependencies: .init(), + sourceLanguages: [SourceLanguage(name: "swift"), SourceLanguage(name: "objc")]) + let externalRenderNode = ExternalRenderNode( + externalEntity: externalEntity, + bundleIdentifier: "com.test.external" + ) + + let swiftNavigatorExternalRenderNode = try XCTUnwrap( + NavigatorExternalRenderNode(renderNode: externalRenderNode) + ) + XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.title, swiftTitle) + XCTAssertEqual(swiftNavigatorExternalRenderNode.metadata.navigatorTitle, navigatorTitle) + XCTAssertTrue(swiftNavigatorExternalRenderNode.metadata.isBeta) + + let objcNavigatorExternalRenderNode = try XCTUnwrap( + NavigatorExternalRenderNode(renderNode: externalRenderNode, trait: .interfaceLanguage("objc")) + ) + XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.title, occTitle) + XCTAssertEqual(objcNavigatorExternalRenderNode.metadata.navigatorTitle, occNavigatorTitle) + XCTAssertTrue(objcNavigatorExternalRenderNode.metadata.isBeta) + } } diff --git a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift index 129d86633..36c9099d8 100644 --- a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift +++ b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift @@ -29,6 +29,7 @@ class TestMultiResultExternalReferenceResolver: ExternalDocumentationSource { var language = SourceLanguage.swift var declarationFragments: SymbolGraph.Symbol.DeclarationFragments? = nil var topicImages: [(TopicImage, alt: String)]? = nil + var platforms: [AvailabilityRenderItem]? = nil } // When more tests use this we may find that there's a better way to describe this (for example by separating @@ -105,6 +106,7 @@ class TestMultiResultExternalReferenceResolver: ExternalDocumentationSource { fragments: entityInfo.declarationFragments?.declarationFragments.map { fragment in return DeclarationRenderSection.Token(fragment: fragment, identifier: nil) }, + isBeta: entityInfo.platforms?.allSatisfy({$0.isBeta == true}) ?? false, images: entityInfo.topicImages?.map(\.0) ?? [] ), renderReferenceDependencies: dependencies, From 7acb796b5bfef530d1ec92dc990c6f9daae0e1b8 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Tue, 15 Jul 2025 15:57:31 +0100 Subject: [PATCH 2/7] Add beta attribute to NavigatorIndexableRenderMetadataRepresentation Adds a computed property to `NavigatorIndexableRenderMetadataRepresentation` which derives whether the navigator item `isBeta` or not. This uses the same logic used in other places in the codebase [1]. Fixes rdar://155521394. [1]: https://github.com/swiftlang/swift-docc/blob/f968935b770b0011d7aa28f59eda22a8407282b7/Sources/SwiftDocC/Infrastructure/External%20Data/OutOfProcessReferenceResolver.swift#L592-L598 --- .../Navigator/RenderNode+NavigatorIndex.swift | 11 ++ ...avigatorIndexableRenderMetadataTests.swift | 134 ++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 Tests/SwiftDocCTests/Indexing/NavigatorIndexableRenderMetadataTests.swift diff --git a/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift b/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift index 88ec8bf36..7200c5fb0 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift @@ -40,6 +40,7 @@ protocol NavigatorIndexableRenderMetadataRepresentation { var roleHeading: String? { get } var symbolKind: String? { get } var platforms: [AvailabilityRenderItem]? { get } + var isBeta: Bool { get } } extension NavigatorIndexableRenderNodeRepresentation { @@ -122,6 +123,16 @@ struct RenderNodeVariantView: NavigatorIndexableRenderNodeRepresentation { } } +extension NavigatorIndexableRenderMetadataRepresentation { + var isBeta: Bool { + guard let platforms, !platforms.isEmpty else { + return false + } + + return platforms.allSatisfy { $0.isBeta == true } + } +} + private let typesThatShouldNotUseNavigatorTitle: Set = [ .framework, .class, .structure, .enumeration, .protocol, .typeAlias, .associatedType, .extension ] diff --git a/Tests/SwiftDocCTests/Indexing/NavigatorIndexableRenderMetadataTests.swift b/Tests/SwiftDocCTests/Indexing/NavigatorIndexableRenderMetadataTests.swift new file mode 100644 index 000000000..3a997e259 --- /dev/null +++ b/Tests/SwiftDocCTests/Indexing/NavigatorIndexableRenderMetadataTests.swift @@ -0,0 +1,134 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import XCTest +@testable import SwiftDocC + +class NavigatorIndexableRenderMetadataTests: XCTestCase { + + // MARK: - Test Helper Methods + + /// Creates a test platform with the specified beta status + private func createPlatform(name: String, isBeta: Bool) -> AvailabilityRenderItem { + return AvailabilityRenderItem(name: name, introduced: "1.0", isBeta: isBeta) + } + + /// Creates a RenderMetadata instance with the specified platforms + private func createRenderMetadata(platforms: [AvailabilityRenderItem]?) -> RenderMetadata { + var metadata = RenderMetadata() + metadata.platforms = platforms + return metadata + } + + /// Creates a RenderMetadataVariantView with the specified platforms + private func createRenderMetadataVariantView(platforms: [AvailabilityRenderItem]?) -> RenderMetadataVariantView { + let metadata = createRenderMetadata(platforms: platforms) + return RenderMetadataVariantView(wrapped: metadata, traits: []) + } + + // MARK: - RenderMetadataVariantView Tests + + func testRenderMetadataVariantViewIsBeta() { + var metadataView = createRenderMetadataVariantView(platforms: nil) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when no platforms are defined") + + metadataView = createRenderMetadataVariantView(platforms: []) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when platforms array is empty") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: false) + ]) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when single platform is non-beta") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: false), + createPlatform(name: "macOS", isBeta: false), + createPlatform(name: "watchOS", isBeta: false) + ]) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when multiple platforms are non-beta") + + var platform1 = AvailabilityRenderItem(name: "iOS", introduced: "1.0", isBeta: false) + platform1.isBeta = nil + var platform2 = AvailabilityRenderItem(name: "macOS", introduced: "1.0", isBeta: false) + platform2.isBeta = nil + + metadataView = createRenderMetadataVariantView(platforms: [platform1, platform2]) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when platforms have nil beta status") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: true), + createPlatform(name: "macOS", isBeta: false), + createPlatform(name: "watchOS", isBeta: true) + ]) + XCTAssertFalse(metadataView.isBeta, "isBeta should be false when some platforms are beta and some are non-beta") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: true) + ]) + XCTAssertTrue(metadataView.isBeta, "isBeta should be true when single platform is beta") + + metadataView = createRenderMetadataVariantView(platforms: [ + createPlatform(name: "iOS", isBeta: true), + createPlatform(name: "macOS", isBeta: true), + createPlatform(name: "watchOS", isBeta: true) + ]) + XCTAssertTrue(metadataView.isBeta, "isBeta should be true when multiple platforms are beta") + } + + // MARK: - RenderMetadata Tests + + func testRenderMetadataIsBeta() { + var metadata = createRenderMetadata(platforms: nil) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when no platforms are defined") + + metadata = createRenderMetadata(platforms: []) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when platforms array is empty") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "macOS", isBeta: false) + ]) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when single platform is non-beta") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "iOS", isBeta: false), + createPlatform(name: "macOS", isBeta: false), + createPlatform(name: "tvOS", isBeta: false) + ]) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when all platforms are non-beta") + + var platform1 = AvailabilityRenderItem(name: "iOS", introduced: "1.0", isBeta: false) + platform1.isBeta = nil + var platform2 = AvailabilityRenderItem(name: "macOS", introduced: "1.0", isBeta: false) + platform2.isBeta = nil + + metadata = createRenderMetadata(platforms: [platform1, platform2]) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when platforms have nil beta status") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "iOS", isBeta: false), + createPlatform(name: "macOS", isBeta: true), + createPlatform(name: "tvOS", isBeta: false) + ]) + XCTAssertFalse(metadata.isBeta, "isBeta should be false when some platforms are beta and some are non-beta") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "macOS", isBeta: true) + ]) + XCTAssertTrue(metadata.isBeta, "isBeta should be true when single platform is beta") + + metadata = createRenderMetadata(platforms: [ + createPlatform(name: "iOS", isBeta: true), + createPlatform(name: "macOS", isBeta: true), + createPlatform(name: "tvOS", isBeta: true) + ]) + XCTAssertTrue(metadata.isBeta, "isBeta should be true when all platforms are beta") + } +} From b7d9ecb5036fae83451cc46c6ea57cd53e14992b Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:02:33 +0100 Subject: [PATCH 3/7] Capture whether an item `isBeta` in the navigator item Propagates the `isBeta` property to the `NavigatorItem` type from `NavigatorIndexableRenderMetadataRepresentation`. When we index a new node, whether the item is beta or not will now be captured as part of the navigator This is preparatory work before this property is propagated to the `RenderIndex` [1]. Fixes rdar://155521394. [1]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift#L257-L259 --- .../Indexing/Navigator/NavigatorIndex.swift | 3 ++- .../Indexing/Navigator/NavigatorItem.swift | 13 +++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift index 8a6e452f4..4024bf3f9 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift @@ -771,7 +771,8 @@ extension NavigatorIndex { platformMask: platformID, availabilityID: UInt64(availabilityID), icon: renderNode.icon, - isExternal: external + isExternal: external, + isBeta: renderNode.metadata.isBeta ) navigationItem.path = identifierPath diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift index 32192682f..2289b1c02 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift @@ -49,6 +49,11 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString var icon: RenderReferenceIdentifier? = nil + /// A value that indicates whether this item is built for a beta platform. + /// + /// This value is `false` if the referenced item is not a symbol. + var isBeta: Bool = false + /// Whether the item has originated from an external reference. /// /// Used for determining whether stray navigation items should remain part of the final navigator. @@ -66,7 +71,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString - path: The path to load the content. - icon: A reference to a custom image for this navigator item. */ - init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false) { + init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, path: String, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false, isBeta: Bool = false) { self.pageType = pageType self.languageID = languageID self.title = title @@ -75,6 +80,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString self.path = path self.icon = icon self.isExternal = isExternal + self.isBeta = isBeta } /** @@ -87,8 +93,10 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString - platformMask: The mask indicating for which platform the page is available. - availabilityID: The identifier of the availability information of the page. - icon: A reference to a custom image for this navigator item. + - isExternal: A flag indicating whether the navigator item belongs to an external documentation archive. + - isBeta: A flag indicating whether the navigator item is in beta. */ - public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false) { + public init(pageType: UInt8, languageID: UInt8, title: String, platformMask: UInt64, availabilityID: UInt64, icon: RenderReferenceIdentifier? = nil, isExternal: Bool = false, isBeta: Bool = false) { self.pageType = pageType self.languageID = languageID self.title = title @@ -96,6 +104,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString self.availabilityID = availabilityID self.icon = icon self.isExternal = isExternal + self.isBeta = isBeta } // MARK: - Serialization and Deserialization From 7b266fab4a4f43ede60e854944a8d3ddf7961639 Mon Sep 17 00:00:00 2001 From: Andrea Fernandez Buitrago <15234535+anferbui@users.noreply.github.com> Date: Wed, 16 Jul 2025 15:54:23 +0100 Subject: [PATCH 4/7] Fix serialisation of isBeta and isExternal properties The isBeta and isExternal properties weren't being serialised as part of `NavigatorItem`. These properties are used to initialise to `RenderIndex.Node` during the conversion to `index.json` [1] and must be preserved when navigator indexes are written to disk [2] so that when they are read [3], we don't drop beta and external information. This serialisation happens during the `finalize` step [4] and the deserialisation can be invoked via `NavigatorIndex.readNavigatorIndex` [5]. Otherwise, the values can be lost on serialisation roundtrip. [1]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift#L329 [2]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Indexing/Navigator/NavigatorTree.swift#L195 [3]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Indexing/Navigator/NavigatorTree.swift#L266 [4]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift#L1193 [5]: https://github.com/swiftlang/swift-docc/blob/65aaf926ec079ddbd40f29540d4180a70af99e5e/Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift#L157 --- .../Indexing/Navigator/NavigatorItem.swift | 28 ++++++++- .../Indexing/NavigatorIndexTests.swift | 60 +++++++++++++++++++ 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift index 2289b1c02..7d626c559 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift @@ -146,8 +146,27 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString let pathData = data[cursor...stride + // To ensure backwards compatibility, handle both when `isBeta` has been encoded and when it hasn't + if cursor < data.count { + let betaValue: UInt8 = unpackedValueFromData(data[cursor.. Date: Wed, 16 Jul 2025 16:11:29 +0100 Subject: [PATCH 5/7] Propagates `isBeta` property to RenderIndex All that was left was that on initialisation of a `RenderIndex.Node`, we propagate the `isBeta` value from the `NavigatorItem`. With this change, beta information is now available in the navigator, encoded as `"beta": true` in the resulting `index.json` file. Fixes rdar://155521394. --- .../RenderIndexJSON/RenderIndex.swift | 9 +-- .../Indexing/ExternalRenderNodeTests.swift | 4 ++ .../Indexing/NavigatorIndexTests.swift | 55 +++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift b/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift index 5140e35fd..250d745b0 100644 --- a/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift +++ b/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift @@ -92,6 +92,7 @@ public struct RenderIndex: Codable, Equatable { pageType: .framework, isDeprecated: false, isExternal: false, + isBeta: false, children: nodes, icon: nil ) @@ -245,6 +246,7 @@ extension RenderIndex { pageType: NavigatorIndex.PageType?, isDeprecated: Bool, isExternal: Bool, + isBeta: Bool, children: [Node], icon: RenderReferenceIdentifier? ) { @@ -253,10 +255,8 @@ extension RenderIndex { self.isDeprecated = isDeprecated self.isExternal = isExternal - - // Currently Swift-DocC doesn't support marking a node as beta in the navigation index - // so we default to `false` here. - self.isBeta = false + self.isBeta = isBeta + self.icon = icon guard let pageType else { @@ -327,6 +327,7 @@ extension RenderIndex.Node { pageType: NavigatorIndex.PageType(rawValue: node.item.pageType), isDeprecated: isDeprecated, isExternal: node.item.isExternal, + isBeta: node.item.isBeta, children: node.children.map { RenderIndex.Node.fromNavigatorTreeNode($0, in: navigatorIndex, with: builder) }, diff --git a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift index 9a9e5bc1e..a8afa4aff 100644 --- a/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift @@ -218,6 +218,10 @@ class ExternalRenderNodeTests: XCTestCase { XCTAssertEqual(occExternalNodes.map(\.title), ["ObjCArticle", "ObjCSymbol"]) XCTAssert(swiftExternalNodes.allSatisfy(\.isExternal)) XCTAssert(occExternalNodes.allSatisfy(\.isExternal)) + XCTAssert(swiftExternalNodes.first { $0.title == "SwiftArticle" }?.isBeta == false) + XCTAssert(swiftExternalNodes.first { $0.title == "SwiftSymbol" }?.isBeta == true) + XCTAssert(occExternalNodes.first { $0.title == "ObjCArticle" }?.isBeta == true) + XCTAssert(occExternalNodes.first { $0.title == "ObjCSymbol" }?.isBeta == false) } func testNavigatorWithExternalNodesOnlyAddsCuratedNodesToNavigator() async throws { diff --git a/Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift b/Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift index f51fe8dec..71ea0f8da 100644 --- a/Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift +++ b/Tests/SwiftDocCTests/Indexing/NavigatorIndexTests.swift @@ -2037,6 +2037,61 @@ Root ) } + func testNavigatorIndexCapturesBetaStatus() async throws { + // Set up configuration with beta platforms + let platformMetadata = [ + "macOS": PlatformVersion(VersionTriplet(1, 0, 0), beta: true), + "watchOS": PlatformVersion(VersionTriplet(2, 0, 0), beta: true), + "tvOS": PlatformVersion(VersionTriplet(3, 0, 0), beta: true), + "iOS": PlatformVersion(VersionTriplet(4, 0, 0), beta: true), + "Mac Catalyst": PlatformVersion(VersionTriplet(4, 0, 0), beta: true), + "iPadOS": PlatformVersion(VersionTriplet(4, 0, 0), beta: true), + ] + var configuration = DocumentationContext.Configuration() + configuration.externalMetadata.currentPlatforms = platformMetadata + + let (_, bundle, context) = try await testBundleAndContext(named: "AvailabilityBetaBundle", configuration: configuration) + let renderContext = RenderContext(documentationContext: context, bundle: bundle) + let converter = DocumentationContextConverter(bundle: bundle, context: context, renderContext: renderContext) + let targetURL = try createTemporaryDirectory() + let builder = NavigatorIndex.Builder(outputURL: targetURL, bundleIdentifier: bundle.id.rawValue, sortRootChildrenByName: true) + builder.setup() + for identifier in context.knownPages { + let entity = try context.entity(with: identifier) + let renderNode = try XCTUnwrap(converter.renderNode(for: entity)) + try builder.index(renderNode: renderNode) + } + builder.finalize() + let renderIndex = try RenderIndex.fromURL(targetURL.appendingPathComponent("index.json")) + + // Find nodes that should have beta status + let swiftNodes = renderIndex.interfaceLanguages["swift"] ?? [] + let betaNodes = findNodesWithBetaStatus(in: swiftNodes, isBeta: true) + let nonBetaNodes = findNodesWithBetaStatus(in: swiftNodes, isBeta: false) + + // Verify that beta status was captured in the render index + XCTAssertEqual(betaNodes.map(\.title), ["MyClass"]) + XCTAssert(betaNodes.allSatisfy(\.isBeta)) // Sanity check + XCTAssertEqual(nonBetaNodes.map(\.title).sorted(), ["Classes", "MyOtherClass", "MyThirdClass"]) + XCTAssert(nonBetaNodes.allSatisfy { $0.isBeta == false }) // Sanity check + } + + private func findNodesWithBetaStatus(in nodes: [RenderIndex.Node], isBeta: Bool) -> [RenderIndex.Node] { + var betaNodes: [RenderIndex.Node] = [] + + for node in nodes { + if node.isBeta == isBeta { + betaNodes.append(node) + } + + if let children = node.children { + betaNodes.append(contentsOf: findNodesWithBetaStatus(in: children, isBeta: isBeta)) + } + } + + return betaNodes + } + func generatedNavigatorIndex(for testBundleName: String, bundleIdentifier: String) async throws -> NavigatorIndex { let (bundle, context) = try await testBundleAndContext(named: testBundleName) let renderContext = RenderContext(documentationContext: context, bundle: bundle) From d8b686e57b971f096d4ec1c6099d5d682ba89944 Mon Sep 17 00:00:00 2001 From: Sofia Rodriguez Morales Date: Thu, 11 Sep 2025 16:41:09 +0100 Subject: [PATCH 6/7] Check that we don't cause an out of bounds crash when accessing the new values. Added an assertion that checks that the cursor plus the lenght of the memory to be decoded is less or equal than the length of the entire memory for the raw value that has been passed, avoiding a runtime crash. Also both if statements were combined into a signgle one since, as pointed out in [1], `isBeta` and `isExternal` have to either both exists or not. --- [1] https://github.com/swiftlang/swift-docc/pull/1249#discussion_r2259784235 --- .../SwiftDocC/Indexing/Navigator/NavigatorItem.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift index 7d626c559..72b424330 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift @@ -152,15 +152,15 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString // Without proper serialization, these indicators would be lost when navigator indexes are loaded from disk. length = MemoryLayout.stride - // To ensure backwards compatibility, handle both when `isBeta` has been encoded and when it hasn't + // To ensure backwards compatibility, handle both when `isBeta` and `isExternal` has been encoded and when it hasn't if cursor < data.count { + // Encoded `isBeta` + assert(cursor + length <= data.count, "The serialized data is malformed: `isBeta` value should not extend past the end of the data") let betaValue: UInt8 = unpackedValueFromData(data[cursor.. Date: Thu, 11 Sep 2025 16:52:41 +0100 Subject: [PATCH 7/7] Update license year in the header of the modified files. --- Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift | 2 +- .../Indexing/Navigator/RenderNode+NavigatorIndex.swift | 2 +- Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift | 2 +- .../Infrastructure/TestExternalReferenceResolvers.swift | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift index 72b424330..d3d1d3a30 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021-2024 Apple Inc. and the Swift project authors + Copyright (c) 2021-2025 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information diff --git a/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift b/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift index 7200c5fb0..21fc7aa5f 100644 --- a/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift +++ b/Sources/SwiftDocC/Indexing/Navigator/RenderNode+NavigatorIndex.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2024 Apple Inc. and the Swift project authors + Copyright (c) 2024-2025 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information diff --git a/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift b/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift index 250d745b0..7b228244b 100644 --- a/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift +++ b/Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2022-2024 Apple Inc. and the Swift project authors + Copyright (c) 2022-2025 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information diff --git a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift index 36c9099d8..3b135aa23 100644 --- a/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift +++ b/Tests/SwiftDocCTests/Infrastructure/TestExternalReferenceResolvers.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2023-2024 Apple Inc. and the Swift project authors + Copyright (c) 2023-2025 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information