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
170 changes: 103 additions & 67 deletions Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -622,84 +622,120 @@ public struct RenderNodeTranslator: SemanticVisitor {
node.primaryContentSections.append(ContentRenderSection(kind: .content, content: discussionContent, heading: title))
}

if let topics = article.topics, !topics.taskGroups.isEmpty {
// Don't set an eyebrow as collections and groups don't have one; append the authored Topics section
node.topicSections.append(
contentsOf: renderGroups(
topics,
allowExternalLinks: false,
allowedTraits: documentationNode.availableVariantTraits,
contentCompiler: &contentCompiler
node.topicSectionsVariants = VariantCollection<[TaskGroupRenderSection]>(
from: documentationNode.availableVariantTraits,
fallbackDefaultValue: []
) { trait in
var sections = [TaskGroupRenderSection]()

if let topics = article.topics, !topics.taskGroups.isEmpty {
// Don't set an eyebrow as collections and groups don't have one; append the authored Topics section
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm seeing this comment that we shouldn't add eyebrow text for collection pages, but then I don't see the corresponding else branch where we do add eyebrow text for articles that don't have task groups. Am I missing something here?

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah okay. Now I'm seeing that below outside of the topic sections variants bit.

if node.topicSections.isEmpty {
     // Set an eyebrow for articles
     node.metadata.roleHeading = "Article"
}

Maybe the comment just doesn't apply any more and we should remove it? The ordering of when we set the eyebrow text versus set the topic sections shouldn't matter.

Copy link
Contributor

@ethan-kusters ethan-kusters Mar 14, 2022

Choose a reason for hiding this comment

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

Final thought- we should probably make API collection pages only available in Objective-C or Swift if it curates languages of one or the other language.

Otherwise would need to call it an article in one variant and a collection group in the other which doesn't make sense.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, that's an interesting edge case. I'm not convinced that API collection pages should only be available in the language in which their topics are curated, because the API collection page could contain prose content that's interesting regardless of the language. It's easy to create an API collection accidentally (i.e., by just adding topics to an Article). Maybe we can move this conversation to the forums. Separate from that, this would be technically challenging because we'd need to parse the contents of the page before assigning its available languages.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah that's fair. Maybe the right default behavior is that we should turn it into an Article in one language and an API collection page in the other.

Then as a future enhancement we should add a metadata directive that limits a given page to one language or the other.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Then as a future enhancement we should add a metadata directive that limits a given page to one language or the other.

I like that idea. A directive that forces the languages in which an article (and framework, maybe?) is available. Something to discuss in more detail in the forums.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

sections.append(
contentsOf: renderGroups(
topics,
allowExternalLinks: false,
allowedTraits: [trait],
contentCompiler: &contentCompiler
)
)
)
}

// Place "top" rendering preference automatic task groups
// after any user-defined task groups but before automatic curation.
if !article.automaticTaskGroups.isEmpty {
node.topicSections.append(contentsOf: renderAutomaticTaskGroupsSection(article.automaticTaskGroups.filter({ $0.renderPositionPreference == .top }), contentCompiler: &contentCompiler))
}

// If there are no manually curated topics, and no automatic groups, try generating automatic groups by child kind.
if (article.topics == nil || article.topics?.taskGroups.isEmpty == true) &&
article.automaticTaskGroups.isEmpty {
// If there are no authored child topics in docs or markdown,
// inspect the topic graph, find this node's children, and
// for the ones found curate them automatically in task groups.
// Automatic groups are named after the child's kind, e.g.
// "Methods", "Variables", etc.
let alreadyCurated = Set(node.topicSections.flatMap { $0.identifiers })
let groups = try! AutomaticCuration.topics(for: documentationNode, withTrait: nil, context: context)
.compactMap({ group -> AutomaticCuration.TaskGroup? in
// Remove references that have been already curated.
let newReferences = group.references.filter { !alreadyCurated.contains($0.absoluteString) }
// Remove groups that have no uncurated references
guard !newReferences.isEmpty else { return nil }

return (title: group.title, references: newReferences)
})
}

// Collect all child topic references.
contentCompiler.collectedTopicReferences.append(contentsOf: groups.flatMap(\.references))
// Add the final groups to the node.
node.topicSections.append(contentsOf: groups.map(TaskGroupRenderSection.init(taskGroup:)))
}
// Place "top" rendering preference automatic task groups
// after any user-defined task groups but before automatic curation.
if !article.automaticTaskGroups.isEmpty {
sections.append(
contentsOf: renderAutomaticTaskGroupsSection(
article.automaticTaskGroups.filter { $0.renderPositionPreference == .top },
contentCompiler: &contentCompiler
)
)
}

// If there are no manually curated topics, and no automatic groups, try generating automatic groups by
// child kind.
if (article.topics == nil || article.topics?.taskGroups.isEmpty == true) &&
article.automaticTaskGroups.isEmpty {
// If there are no authored child topics in docs or markdown,
// inspect the topic graph, find this node's children, and
// for the ones found curate them automatically in task groups.
// Automatic groups are named after the child's kind, e.g.
// "Methods", "Variables", etc.
let alreadyCurated = Set(node.topicSections.flatMap { $0.identifiers })
let groups = try! AutomaticCuration.topics(for: documentationNode, withTrait: nil, context: context)
.compactMap({ group -> AutomaticCuration.TaskGroup? in
// Remove references that have been already curated.
let newReferences = group.references.filter { !alreadyCurated.contains($0.absoluteString) }
// Remove groups that have no uncurated references
guard !newReferences.isEmpty else { return nil }

return (title: group.title, references: newReferences)
})

// Collect all child topic references.
contentCompiler.collectedTopicReferences.append(contentsOf: groups.flatMap(\.references))
// Add the final groups to the node.
sections.append(contentsOf: groups.map(TaskGroupRenderSection.init(taskGroup:)))
}

// Place "bottom" rendering preference automatic task groups
// after any user-defined task groups but before automatic curation.
if !article.automaticTaskGroups.isEmpty {
sections.append(
contentsOf: renderAutomaticTaskGroupsSection(
article.automaticTaskGroups.filter { $0.renderPositionPreference == .bottom },
contentCompiler: &contentCompiler
)
)
}

return sections
} ?? .init(defaultValue: [])

// Place "bottom" rendering preference automatic task groups
// after any user-defined task groups but before automatic curation.
if !article.automaticTaskGroups.isEmpty {
node.topicSections.append(contentsOf: renderAutomaticTaskGroupsSection(article.automaticTaskGroups.filter({ $0.renderPositionPreference == .bottom }), contentCompiler: &contentCompiler))
}

if node.topicSections.isEmpty {
// Set an eyebrow for articles
node.metadata.roleHeading = "Article"
}
node.metadata.role = contentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue

// Authored See Also section
if let seeAlso = article.seeAlso, !seeAlso.taskGroups.isEmpty {
node.seeAlsoSections.append(
contentsOf: renderGroups(
seeAlso,
allowExternalLinks: true,
allowedTraits: documentationNode.availableVariantTraits,
contentCompiler: &contentCompiler
node.seeAlsoSectionsVariants = VariantCollection<[TaskGroupRenderSection]>(
from: documentationNode.availableVariantTraits,
fallbackDefaultValue: []
) { trait in
var seeAlsoSections = [TaskGroupRenderSection]()

// Authored See Also section
if let seeAlso = article.seeAlso, !seeAlso.taskGroups.isEmpty {
seeAlsoSections.append(
contentsOf: renderGroups(
seeAlso,
allowExternalLinks: true,
allowedTraits: [trait],
contentCompiler: &contentCompiler
)
)
)
}

// Automatic See Also section
if let seeAlso = try! AutomaticCuration.seeAlso(for: documentationNode, context: context, bundle: bundle, renderContext: renderContext, renderer: contentRenderer) {
contentCompiler.collectedTopicReferences.append(contentsOf: seeAlso.references)
node.seeAlsoSections.append(TaskGroupRenderSection(
title: seeAlso.title,
abstract: nil,
discussion: nil,
identifiers: seeAlso.references.map { $0.absoluteString },
generated: true
))
}
}

// Automatic See Also section
if let seeAlso = try! AutomaticCuration.seeAlso(
for: documentationNode,
context: context,
bundle: bundle,
renderContext: renderContext,
renderer: contentRenderer
) {
contentCompiler.collectedTopicReferences.append(contentsOf: seeAlso.references)
seeAlsoSections.append(TaskGroupRenderSection(
title: seeAlso.title,
abstract: nil,
discussion: nil,
identifiers: seeAlso.references.map { $0.absoluteString },
generated: true
))
}

return seeAlsoSections
} ?? .init(defaultValue: [])

collectedTopicReferences.append(contentsOf: contentCompiler.collectedTopicReferences)
node.references = createTopicRenderReferences()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,19 @@ extension KeyedEncodingContainer {
)
}

/// Encodes the given variant collection.
/// Encodes the given variant collection for its non-empty values.
mutating func encodeVariantCollectionIfNotEmpty<Value>(
_ variantCollection: VariantCollection<Value>,
forKey key: Key,
encoder: Encoder
) throws where Value: Collection {
try encodeIfNotEmpty(variantCollection.defaultValue, forKey: key)

variantCollection.addVariantsToEncoder(
variantCollection.mapValues { value in
// Encode `nil` if the value is empty, so that when the patch is applied, it effectively
// removes the default value.
value.isEmpty ? nil : value
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice 👍🏻

Will this apply to every time we encode a collection to a variant? (Are there other code paths that achieve this similar thing?)

I ran into bugs where I would end up with an empty topic section instead of a removed one a few times so making this happen at the encoding level makes a ton of sense to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It will apply every time we call encodeVariantCollectionIfNotEmpty in the encoder.

I ran into bugs where I would end up with an empty topic section instead of a removed one a few times so making this happen at the encoding level makes a ton of sense to me.

Yes, this is exactly what this code is fixing :)

}.addVariantsToEncoder(
encoder,

// Add the key to the encoder's coding path, since the coding path refers to the value's parent.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2022 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

public extension VariantCollection {
/// A variant for a render node value.
struct Variant<Value: Codable> {
/// The traits associated with the override.
public var traits: [RenderNode.Variant.Trait]

/// The patch to apply as part of the override.
public var patch: [VariantPatchOperation<Value>]

/// Creates an override value for the given traits.
///
/// - Parameters:
/// - traits: The traits associated with this override value.
/// - patch: The patch to apply as part of the override.
public init(traits: [RenderNode.Variant.Trait], patch: [VariantPatchOperation<Value>]) {
self.traits = traits
self.patch = patch
}

/// Returns a new variant collection containing the traits of this variant collection with the values transformed by the given closure.
public func mapPatch<TransformedValue>(
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this might be easier to understand at the call site without much added verbosity with just patch.map instead of the helper function mapPatch. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, the reason I called it mapPatch was to align with Dictionary's mapValues. It would be confusing to have mapTraits and map, if we ever want to support mapping of the traits.

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah I more meant- do we need this function at all?

Couldn't I just do:

<my-variant-collection>.patch.map { ... }

Instead of

<my-variant-collection>.patchMap { ... }

?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, gotcha. So the map function in this case would return [VariantPatchOperation<TransformedValue>] and the client would still need to create a VariantCollection<TransformedValue> because they wouldn't be able to assign the result of the map to VariantCollection<Value> (different generic arguments). So I think keeping that complexity in this function makes sense

Copy link
Contributor

Choose a reason for hiding this comment

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

Ohhh. Tricky. Yup- that makes sense. Thanks!

_ transform: (Value) -> TransformedValue
) -> VariantCollection<TransformedValue>.Variant<TransformedValue> {
VariantCollection<TransformedValue>.Variant<TransformedValue>(
traits: traits,
patch: patch.map { patchOperation in patchOperation.map(transform) }
)
}
}
}
31 changes: 11 additions & 20 deletions Sources/SwiftDocC/Model/Rendering/Variants/VariantCollection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,25 +91,16 @@ public struct VariantCollection<Value: Codable>: Codable {

encoder.userInfoVariantOverrides?.add(contentsOf: overrides)
}
}

public extension VariantCollection {
/// A variant for a render node value.
struct Variant<Value: Codable> {
/// The traits associated with the override.
public var traits: [RenderNode.Variant.Trait]

/// The patch to apply as part of the override.
public var patch: [VariantPatchOperation<Value>]

/// Creates an override value for the given traits.
///
/// - Parameters:
/// - traits: The traits associated with this override value.
/// - patch: The patch to apply as part of the override.
public init(traits: [RenderNode.Variant.Trait], patch: [VariantPatchOperation<Value>]) {
self.traits = traits
self.patch = patch
}

/// Returns a variant collection containing the results of calling the given transformation with each value of this variant collection.
public func mapValues<TransformedValue>(
_ transform: (Value) -> TransformedValue
) -> VariantCollection<TransformedValue> {
VariantCollection<TransformedValue>(
defaultValue: transform(defaultValue),
variants: variants.map { variant in
variant.mapPatch(transform)
}
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ public enum VariantPatchOperation<Value: Codable> {
/// - Parameter value: The value to use in the replacement.
case replace(value: Value)

/// An addition operation.
///
/// - Parameter value: The value to use in the addition.
case add(value: Value)

/// A removal operation.
Expand All @@ -33,6 +36,24 @@ public enum VariantPatchOperation<Value: Codable> {
return .remove
}
}

/// Returns a new patch operation with its value transformed using the given closure.
///
/// If the patch operation doesn't have a value—for example, if it's a removal operation—the operation is returned unmodified.
func map<TransformedValue>(
_ transform: (Value) -> TransformedValue
) -> VariantPatchOperation<TransformedValue> {
switch self {
case .replace(let value):
return VariantPatchOperation<TransformedValue>.replace(value: transform(value))

case .add(let value):
return VariantPatchOperation<TransformedValue>.add(value: transform(value))

case .remove:
return .remove
}
}
}

extension VariantCollection.Variant where Value: RangeReplaceableCollection {
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftDocC/Semantics/Article/Article.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ public final class Article: Semantic, MarkupConvertible, Abstracted, Redirected,
private(set) public var abstractSection: AbstractSection?

/// The Topic curation section of the article.
private(set) public var topics: TopicsSection?
internal(set) public var topics: TopicsSection?

/// The See Also section of the article.
private(set) public var seeAlso: SeeAlsoSection?
Expand Down
43 changes: 43 additions & 0 deletions Tests/SwiftDocCTests/Indexing/RenderIndexTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,22 @@ final class RenderIndexTests: XCTestCase {
"path": "/documentation/mixedlanguageframework/article",
"type": "article"
},
{
"title": "APICollection",
"path": "/documentation/mixedlanguageframework/apicollection",
"type": "symbol",
"children": [
{
"title": "Objective-C–only APIs",
"type": "groupMarker"
},
{
"title": "_MixedLanguageFrameworkVersionNumber",
"path": "/documentation/mixedlanguageframework/_mixedlanguageframeworkversionnumber",
"type": "var"
}
]
},
{
"title": "Classes",
"type": "groupMarker"
Expand Down Expand Up @@ -262,6 +278,33 @@ final class RenderIndexTests: XCTestCase {
"path": "/documentation/mixedlanguageframework/article",
"type": "article"
},
{
"path": "/documentation/mixedlanguageframework/apicollection",
"title": "APICollection",
"type": "symbol",
"children": [
{
"title": "Swift-only APIs",
"type": "groupMarker"
},
{
"path": "/documentation/mixedlanguageframework/swiftonlystruct",
"title": "SwiftOnlyStruct",
"type": "struct",
"children": [
{
"title": "Instance Methods",
"type": "groupMarker"
},
{
"title": "func tada()",
"path": "/documentation/mixedlanguageframework/swiftonlystruct/tada()",
"type": "method"
}
]
}
]
},
{
"title": "Classes",
"type": "groupMarker"
Expand Down
Loading