Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Sources/SwiftDocC/Indexing/Navigator/NavigatorIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 38 additions & 5 deletions Sources/SwiftDocC/Indexing/Navigator/NavigatorItem.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -75,6 +80,7 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
self.path = path
self.icon = icon
self.isExternal = isExternal
self.isBeta = isBeta
}

/**
Expand All @@ -87,15 +93,18 @@ 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
self.platformMask = platformMask
self.availabilityID = availabilityID
self.icon = icon
self.isExternal = isExternal
self.isBeta = isBeta
}

// MARK: - Serialization and Deserialization
Expand Down Expand Up @@ -137,8 +146,27 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString

let pathData = data[cursor..<cursor + Int(pathLength)]
self.path = String(data: pathData, encoding: .utf8)!
cursor += Int(pathLength)

assert(cursor+Int(pathLength) == data.count)
// isBeta and isExternal should be encoded because they are relevant when creating a RenderIndex node.
// Without proper serialization, these indicators would be lost when navigator indexes are loaded from disk.

length = MemoryLayout<UInt8>.stride
// To ensure backwards compatibility, handle both when `isBeta` and `isExternal` has been encoded and when it hasn't
if cursor < data.count {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this check that cursor + length fits within the data? Currently, it's still possible that this code would index out of bounds. (same below)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar to a few lines below I added an assertion to catch if this happens. d3e651d

// 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..<cursor + length])
cursor += length
self.isBeta = betaValue != 0
// Encoded `isExternal`
assert(cursor + length <= data.count, "The serialized data is malformed: `isExternal` value should not extend past the end of the data")
let externalValue: UInt8 = unpackedValueFromData(data[cursor..<cursor + length])
cursor += length
self.isExternal = externalValue != 0
}

assert(cursor == data.count)
}

/// Returns the `Data` representation of the current `NavigatorItem` instance.
Expand All @@ -155,6 +183,9 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
data.append(Data(title.utf8))
data.append(Data(path.utf8))

data.append(packedDataFromValue(isBeta ? UInt8(1) : UInt8(0)))
data.append(packedDataFromValue(isExternal ? UInt8(1) : UInt8(0)))

return data
}

Expand All @@ -167,7 +198,9 @@ public final class NavigatorItem: Serializable, Codable, Equatable, CustomString
languageID: \(languageID),
title: \(title),
platformMask: \(platformMask),
availabilityID: \(availabilityID)
availabilityID: \(availabilityID),
isBeta: \(isBeta),
isExternal: \(isExternal)
}
"""
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -40,6 +40,7 @@ protocol NavigatorIndexableRenderMetadataRepresentation {
var roleHeading: String? { get }
var symbolKind: String? { get }
var platforms: [AvailabilityRenderItem]? { get }
var isBeta: Bool { get }
}

extension NavigatorIndexableRenderNodeRepresentation {
Expand Down Expand Up @@ -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 }
}
Comment on lines +127 to +133
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code it's repeated in LinkDestinationSummary ResolvedInformation and now here. Might be better to unify these so it does not become a maintenance nightmare

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have an idea for how we can do this, by defining this extension:

extension Array where Array.Element == AvailabilityRenderItem {
    var isBeta: Bool {
        guard !self.isEmpty else {
            return false
        }
        
        return self.allSatisfy { $0.isBeta == true }
    }
}

I will open a separate PR with this proposal so that we can review/discuss separately :)

}

private let typesThatShouldNotUseNavigatorTitle: Set<NavigatorIndex.PageType> = [
.framework, .class, .structure, .enumeration, .protocol, .typeAlias, .associatedType, .extension
]
Expand Down
11 changes: 6 additions & 5 deletions Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -92,6 +92,7 @@ public struct RenderIndex: Codable, Equatable {
pageType: .framework,
isDeprecated: false,
isExternal: false,
isBeta: false,
children: nodes,
icon: nil
)
Expand Down Expand Up @@ -245,6 +246,7 @@ extension RenderIndex {
pageType: NavigatorIndex.PageType?,
isDeprecated: Bool,
isExternal: Bool,
isBeta: Bool,
children: [Node],
icon: RenderReferenceIdentifier?
) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
)
}
}
Expand All @@ -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.
Expand Down
76 changes: 69 additions & 7 deletions Tests/SwiftDocCTests/Indexing/ExternalRenderNodeTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,31 +21,35 @@ 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(
.init(
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(
.init(
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(
.init(
referencePath: "/path/to/external/objCSymbol",
title: "ObjCSymbol",
kind: .function,
language: .objectiveC
language: .objectiveC,
platforms: [.init(name: "macOS", introduced: nil, isBeta: false)]
)
)
return externalResolver
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -208,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 {
Expand Down Expand Up @@ -269,4 +283,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)
}
}
Loading