diff --git a/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift b/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift index 49758a3dba..7dd9f6bdcd 100644 --- a/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift +++ b/Sources/SwiftDocC/LinkTargets/LinkDestinationSummary.swift @@ -71,6 +71,8 @@ import SymbolKit /// - In a paragraph of text, a link to this element will use the ``title`` as the link text and style the tile in code font if the ``kind`` is a type of symbol. /// - In a task group, the the ``title`` and ``abstract-swift.property`` is displayed together to give more context about this element and the element may be marked as deprecated /// based on the values of its ``platforms`` and other metadata about the current versions of the platforms. +/// +/// The summary may include content that vary based on the source language. The content that is different in another source language is specified in a ``Variant``. Any property on the variant that is `nil` has the same value as the summarized element's value. public struct LinkDestinationSummary: Codable, Equatable { /// The kind of the summarized element. public let kind: DocumentationNode.Kind @@ -142,6 +144,54 @@ public struct LinkDestinationSummary: Codable, Equatable { /// /// A web server can use this list of URLs to redirect to the current URL. public let redirects: [URL]? + + /// A variant of content for a summarized element. + /// + /// - Note: All properties except for ``traits`` are optional. If a property is `nil` it means that the value is the same as the summarized element's value. + public struct Variant: Codable, Equatable { + /// The traits of the variant. + public let traits: [RenderNode.Variant.Trait] + + /// A wrapper for variant values that can either be specified, meaning the variant has a custom value, or not, meaning the variant has the same value as the summarized element. + /// + /// This alias is used to make the property declarations more explicit while at the same time offering the convenient syntax of optionals. + public typealias VariantValue = Optional + + /// The kind of the variant or `nil` if the kind is the same as the summarized element. + public let kind: VariantValue + + /// The source language of the variant or `nil` if the kind is the same as the summarized element. + public let language: VariantValue + + /// The relative path of the variant or `nil` if the relative is the same as the summarized element. + public let path: VariantValue + + /// The title of the variant or `nil` if the title is the same as the summarized element. + public let title: VariantValue + + /// The abstract of the variant or `nil` if the abstract is the same as the summarized element. + /// + /// If the summarized element has an abstract but the variant doesn't, this property will be `Optional.some(nil)`. + public let abstract: VariantValue + + /// The taskGroups of the variant or `nil` if the taskGroups is the same as the summarized element. + /// + /// If the summarized element has task groups but the variant doesn't, this property will be `Optional.some(nil)`. + public let taskGroups: VariantValue<[TaskGroup]?> + + /// The precise symbol identifier of the variant or `nil` if the precise symbol identifier is the same as the summarized element. + /// + /// If the summarized element has a precise symbol identifier but the variant doesn't, this property will be `Optional.some(nil)`. + public let usr: VariantValue + + /// The declaration of the variant or `nil` if the declaration is the same as the summarized element. + /// + /// If the summarized element has a declaration but the variant doesn't, this property will be `Optional.some(nil)`. + public let declarationFragments: VariantValue + } + + /// The variants of content (kind, title, abstract, path, urs, declaration, and task groups) for this summarized element. + public let variants: [Variant] } // MARK: - Accessing the externally linkable elements @@ -205,11 +255,7 @@ extension LinkDestinationSummary { /// - taskGroups: The task groups that lists the children of this page. /// - compiler: The content compiler that's used to render the node's abstract. init(documentationNode: DocumentationNode, path: String, taskGroups: [TaskGroup], platforms: [PlatformAvailability]?, compiler: inout RenderContentCompiler) { - let declaration = (documentationNode.semantic as? Symbol)?.subHeading.map { declaration in - return declaration.map { fragment in - DeclarationRenderSection.Token(fragment: fragment, identifier: nil) - } - } + let symbol = documentationNode.semantic as? Symbol self.init( kind: documentationNode.kind, @@ -221,9 +267,10 @@ extension LinkDestinationSummary { availableLanguages: documentationNode.availableSourceLanguages, platforms: platforms, taskGroups: taskGroups, - usr: (documentationNode.semantic as? Symbol)?.externalID, - declarationFragments: declaration, - redirects: (documentationNode.semantic as? Redirected)?.redirects?.map { $0.oldPath } + usr: symbol?.externalID, + declarationFragments: symbol?.subHeading?.map { .init(fragment: $0, identifier: nil) }, + redirects: (documentationNode.semantic as? Redirected)?.redirects?.map { $0.oldPath }, + variants: [] ) } } @@ -240,13 +287,13 @@ extension LinkDestinationSummary { init(landmark: Landmark, basePath: String, page: DocumentationNode, platforms: [PlatformAvailability]?, compiler: inout RenderContentCompiler) { let anchor = urlReadableFragment(landmark.title) - let abstract: Abstract + let abstract: Abstract? if let abstracted = landmark as? Abstracted { abstract = abstracted.renderedAbstract(using: &compiler) ?? [] } else if let paragraph = landmark.markup.children.lazy.compactMap({ $0 as? Paragraph }).first, case RenderBlockContent.paragraph(let inlineContent)? = compiler.visitParagraph(paragraph).first { abstract = inlineContent } else { - abstract = [] + abstract = nil } self.init( @@ -261,7 +308,8 @@ extension LinkDestinationSummary { taskGroups: [], // Landmarks have no children usr: nil, // Only symbols have a USR declarationFragments: nil, // Only symbols have declarations - redirects: (landmark as? Redirected)?.redirects?.map { $0.oldPath } + redirects: (landmark as? Redirected)?.redirects?.map { $0.oldPath }, + variants: [] ) } } @@ -271,7 +319,7 @@ extension LinkDestinationSummary { // Add Codable methods—which include an initializer—in an extension so that it doesn't override the member-wise initializer. extension LinkDestinationSummary { enum CodingKeys: String, CodingKey { - case kind, path, referenceURL, title, abstract, language, taskGroups, usr, availableLanguages, platforms, redirects + case kind, path, referenceURL, title, abstract, language, taskGroups, usr, availableLanguages, platforms, redirects, variants case declarationFragments = "fragments" } @@ -289,6 +337,9 @@ extension LinkDestinationSummary { try container.encodeIfPresent(usr, forKey: .usr) try container.encodeIfPresent(declarationFragments, forKey: .declarationFragments) try container.encodeIfPresent(redirects, forKey: .redirects) + if !variants.isEmpty { + try container.encode(variants, forKey: .variants) + } } public init(from decoder: Decoder) throws { @@ -320,5 +371,64 @@ extension LinkDestinationSummary { usr = try container.decodeIfPresent(String.self, forKey: .usr) declarationFragments = try container.decodeIfPresent(DeclarationFragments.self, forKey: .declarationFragments) redirects = try container.decodeIfPresent([URL].self, forKey: .redirects) + + variants = try container.decodeIfPresent([Variant].self, forKey: .variants) ?? [] + } +} + +extension LinkDestinationSummary.Variant { + enum CodingKeys: String, CodingKey { + case traits, kind, path, title, abstract, language, usr, declarationFragments = "fragments", taskGroups + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(traits, forKey: .traits) + try container.encodeIfPresent(kind?.id, forKey: .kind) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(title, forKey: .title) + try container.encodeIfPresent(abstract, forKey: .abstract) + try container.encodeIfPresent(language, forKey: .language) + try container.encodeIfPresent(usr, forKey: .usr) + try container.encodeIfPresent(declarationFragments, forKey: .declarationFragments) + try container.encodeIfPresent(taskGroups, forKey: .taskGroups) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let traits = try container.decode([RenderNode.Variant.Trait].self, forKey: .traits) + for case .interfaceLanguage(let languageID) in traits { + guard SourceLanguage.knownLanguages.contains(where: { $0.id == languageID }) else { + throw DecodingError.dataCorruptedError(forKey: .traits, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") + } + } + self.traits = traits + + let kindID = try container.decodeIfPresent(String.self, forKey: .kind) + if let kindID = kindID { + guard let foundKind = DocumentationNode.Kind.allKnownValues.first(where: { $0.id == kindID }) else { + throw DecodingError.dataCorruptedError(forKey: .kind, in: container, debugDescription: "Unknown DocumentationNode.Kind identifier: '\(kindID)'.") + } + kind = foundKind + } else { + kind = nil + } + + let languageID = try container.decodeIfPresent(String.self, forKey: .language) + if let languageID = languageID { + guard let foundLanguage = SourceLanguage.knownLanguages.first(where: { $0.id == languageID }) else { + throw DecodingError.dataCorruptedError(forKey: .language, in: container, debugDescription: "Unknown SourceLanguage identifier: '\(languageID)'.") + } + language = foundLanguage + } else { + language = nil + } + path = try container.decodeIfPresent(String.self, forKey: .path) + title = try container.decodeIfPresent(String?.self, forKey: .title) + abstract = try container.decodeIfPresent(LinkDestinationSummary.Abstract?.self, forKey: .abstract) + usr = try container.decodeIfPresent(String?.self, forKey: .title) + declarationFragments = try container.decodeIfPresent(LinkDestinationSummary.DeclarationFragments?.self, forKey: .declarationFragments) + taskGroups = try container.decodeIfPresent([LinkDestinationSummary.TaskGroup]?.self, forKey: .taskGroups) } } diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/LinkableEntities.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/LinkableEntities.json index ee9efa13ab..d343354a7a 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/LinkableEntities.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/LinkableEntities.json @@ -2,7 +2,7 @@ "openapi": "3.0.0", "info": { "description": "Specification of the DocC linkable-entities.json digest file.", - "version": "0.1.0", + "version": "0.2.0", "title": "Linkable Entities" }, "paths": { }, @@ -78,6 +78,12 @@ "items": { "type": "string" } + }, + "variants": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LinkDestinationSummaryVariant" + } } } }, @@ -96,6 +102,71 @@ } } }, + "RenderNodeVariantTrait": { + "oneOf": [ + { + "$ref": "#/components/schemas/TraitInterfaceLanguage" + } + ] + }, + "TraitInterfaceLanguage": { + "required": [ + "interfaceLanguage" + ], + "type": "object", + "properties": { + "interfaceLanguage": { + "type": "string" + } + } + }, + "LinkDestinationSummaryVariant": { + "type": "object", + "required": [ + "traits", + ], + "properties": { + "traits": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RenderNodeVariantTrait" + } + }, + "kind": { + "type": "string" + }, + "language": { + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "abstract": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RenderInlineContent" + } + }, + "usr": { + "type": "string" + }, + "declarationFragments": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DeclarationToken" + } + }, + "taskGroups": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskGroup" + } + } + } + }, "RenderInlineContent": { "oneOf": [ { diff --git a/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift b/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift index 864297211d..43cb99525e 100644 --- a/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift +++ b/Tests/SwiftDocCTests/LinkTargets/LinkDestinationSummaryTests.swift @@ -105,7 +105,9 @@ class ExternalLinkableTests: XCTestCase { let node = try context.entity(with: ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/tutorials/TestBundle/Tutorial", sourceLanguage: .swift)) let renderNode = try converter.convert(node, at: nil) - let pageSummary = node.externallyLinkableElementSummaries(context: context, renderNode: renderNode)[0] + + let summaries = node.externallyLinkableElementSummaries(context: context, renderNode: renderNode) + let pageSummary = summaries[0] XCTAssertEqual(pageSummary.title, "Basic Augmented Reality App") XCTAssertEqual(pageSummary.path, "/tutorials/testbundle/tutorial") XCTAssertEqual(pageSummary.referenceURL.absoluteString, "doc://com.test.example/tutorials/TestBundle/Tutorial") @@ -123,7 +125,7 @@ class ExternalLinkableTests: XCTestCase { XCTAssertNil(pageSummary.declarationFragments, "Only symbols have declaration fragments") XCTAssertNil(pageSummary.abstract, "There is no text to use as an abstract for the tutorial page") - let sectionSummary = node.externallyLinkableElementSummaries(context: context, renderNode: renderNode)[1] + let sectionSummary = summaries[1] XCTAssertEqual(sectionSummary.title, "Create a New AR Project") XCTAssertEqual(sectionSummary.path, "/tutorials/testbundle/tutorial#Create-a-New-AR-Project") XCTAssertEqual(sectionSummary.referenceURL.absoluteString, "doc://com.test.example/tutorials/TestBundle/Tutorial#Create-a-New-AR-Project") @@ -142,6 +144,11 @@ class ExternalLinkableTests: XCTestCase { .text(" "), .text("ut labore et dolore magna aliqua. Phasellus faucibus scelerisque eleifend donec pretium."), ]) + + // Test that the summaries can be decoded from the encoded data + let encoded = try JSONEncoder().encode(summaries) + let decoded = try JSONDecoder().decode([LinkDestinationSummary].self, from: encoded) + XCTAssertEqual(summaries, decoded) } func testSymbolSummaries() throws { @@ -270,6 +277,64 @@ class ExternalLinkableTests: XCTestCase { ]) } } + func testDecodingLegacyData() throws { + let legacyData = """ + { + "title": "ClassName", + "referenceURL": "doc://org.swift.docc.example/documentation/MyKit/ClassName", + "language": "swift", + "path": "documentation/MyKit/ClassName", + "availableLanguages": [ + "swift" + ], + "kind": "org.swift.docc.kind.class", + "abstract": [ + { + "type": "text", + "text": "A brief explanation of my class." + } + ], + "platforms": [ + { + "name": "PlatformName", + "introducedAt": "1.0" + }, + ], + "fragments": [ + { + "kind": "keyword", + "text": "class" + }, + { + "kind": "text", + "text": " " + }, + { + "kind": "identifier", + "text": "ClassName" + } + ] + } + """.data(using: .utf8)! + + let decoded = try JSONDecoder().decode(LinkDestinationSummary.self, from: legacyData) + + XCTAssertEqual(decoded.referenceURL, ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/documentation/MyKit/ClassName", sourceLanguage: .swift).url) + XCTAssertEqual(decoded.platforms?.count, 1) + XCTAssertEqual(decoded.platforms?.first?.name, "PlatformName") + XCTAssertEqual(decoded.platforms?.first?.introduced, "1.0") + XCTAssertEqual(decoded.kind, .class) + XCTAssertEqual(decoded.title, "ClassName") + XCTAssertEqual(decoded.abstract?.plainText, "A brief explanation of my class.") + XCTAssertEqual(decoded.path, "documentation/MyKit/ClassName") + XCTAssertEqual(decoded.declarationFragments, [ + .init(text: "class", kind: .keyword, identifier: nil), + .init(text: " ", kind: .text, identifier: nil), + .init(text: "ClassName", kind: .identifier, identifier: nil), + ]) + + XCTAssert(decoded.variants.isEmpty) + } // Workaround that addTeardownBlock doesn't exist in swift-corelibs-xctest diff --git a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift index 36d5a68b7e..3008d736fa 100644 --- a/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift +++ b/Tests/SwiftDocCUtilitiesTests/ConvertActionTests.swift @@ -2389,13 +2389,14 @@ private extension LinkDestinationSummary { path: path, referenceURL: referenceURL, title: title, - abstract: abstract.map { [.text($0)] } ?? [], + abstract: abstract.map { [.text($0)] }, availableLanguages: availableLanguages, platforms: platforms, taskGroups: taskGroups, usr: usr, declarationFragments: nil, - redirects: redirects + redirects: redirects, + variants: [] ) } }