Skip to content

Commit feff3f7

Browse files
authored
Record article mentions of symbols (#809)
Adds an optional, new section to symbol documentation pages called "Mentioned In", containing up to five links to articles that mention the symbol in the abstract or discussion, scored by mention count (abstract mentions are worth more). The purpose of this section is to highlight explanatory and conceptual content in which the symbol plays an important or central role. This functionality is currently hidden by the `--enable-experimental-mentioned-in` flag. rdar://121899474
1 parent cfc261f commit feff3f7

File tree

16 files changed

+415
-16
lines changed

16 files changed

+415
-16
lines changed

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,8 +297,10 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
297297

298298
/// External metadata injected into the context, for example via command line arguments.
299299
public var externalMetadata = ExternalMetadata()
300-
301-
300+
301+
/// Mentions of symbols within articles.
302+
var articleSymbolMentions = ArticleSymbolMentions()
303+
302304
/// The decoder used in the `SymbolGraphLoader`
303305
var decoder: JSONDecoder = JSONDecoder()
304306

@@ -624,6 +626,23 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
624626

625627
for result in results.sync({ $0 }) {
626628
documentationCache[result.reference] = result.node
629+
630+
if FeatureFlags.current.isExperimentalMentionedInEnabled {
631+
// Record symbol links as symbol "mentions" for automatic cross references
632+
// on rendered symbol documentation.
633+
if let article = result.node.semantic as? Article,
634+
case .article = DocumentationContentRenderer.roleForArticle(article, nodeKind: result.node.kind) {
635+
for markup in article.abstractSection?.content ?? [] {
636+
var mentions = SymbolLinkCollector(context: self, article: result.node.reference, baseWeight: 2)
637+
mentions.visit(markup)
638+
}
639+
for markup in article.discussion?.content ?? [] {
640+
var mentions = SymbolLinkCollector(context: self, article: result.node.reference, baseWeight: 1)
641+
mentions.visit(markup)
642+
}
643+
}
644+
}
645+
627646
assert(
628647
// If this is a symbol, verify that the reference exist in the in the symbolIndex
629648
result.node.symbol.map { symbolIndex[$0.identifier.precise] == result.reference }
@@ -1868,7 +1887,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
18681887
for anchor in documentation.anchorSections {
18691888
nodeAnchorSections[anchor.reference] = anchor
18701889
}
1871-
1890+
18721891
var article = article
18731892
// Update the article's topic graph node with the one we just added to the topic graph.
18741893
article.topicGraphNode = graphNode

Sources/SwiftDocC/Model/Rendering/Diffing/AnyRenderSection.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ struct AnyRenderSection: Equatable, Encodable, RenderJSONDiffable {
5454
return (value as! SampleDownloadSection).difference(from: (other.value as! SampleDownloadSection), at: path)
5555
case (.taskGroup, .taskGroup):
5656
return (value as! TaskGroupRenderSection).difference(from: (other.value as! TaskGroupRenderSection), at: path)
57-
57+
case (.mentions, .mentions):
58+
return (value as! MentionsRenderSection).difference(from: (other.value as! MentionsRenderSection), at: path)
59+
5860
// MARK: Tutorial Sections
5961

6062
case (.intro, .intro), (.hero, .hero):
@@ -119,7 +121,9 @@ struct AnyRenderSection: Equatable, Encodable, RenderJSONDiffable {
119121
return (lhs.value as! SampleDownloadSection) == (rhs.value as! SampleDownloadSection)
120122
case (.taskGroup, .taskGroup):
121123
return (lhs.value as! TaskGroupRenderSection) == (rhs.value as! TaskGroupRenderSection)
122-
124+
case (.mentions, .mentions):
125+
return (lhs.value as! MentionsRenderSection) == (rhs.value as! MentionsRenderSection)
126+
123127
// MARK: Tutorial Sections
124128

125129
case (.intro, .intro), (.hero, .hero):

Sources/SwiftDocC/Model/Rendering/RenderNode/CodableContentSection.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ public struct CodableContentSection: Codable, Equatable {
6565
section = try PlistDetailsRenderSection(from: decoder)
6666
case .possibleValues:
6767
section = try PossibleValuesRenderSection(from: decoder)
68+
case .mentions:
69+
section = try MentionsRenderSection(from: decoder)
6870
default: fatalError()
6971
}
7072
}

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1380,6 +1380,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
13801380
HTTPResponsesSectionTranslator(),
13811381
DictionaryKeysSectionTranslator(),
13821382
ReturnsSectionTranslator(),
1383+
MentionsSectionTranslator(referencingSymbol: identifier),
13831384
DiscussionSectionTranslator(),
13841385
]
13851386
)

Sources/SwiftDocC/Model/Rendering/RenderSection.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ public enum RenderSectionKind: String, Codable {
1717
case hero, intro, tasks, assessments, volume, contentAndMedia, contentAndMediaGroup, callToAction, tile, articleBody, resources
1818

1919
// Symbol render sections
20-
case discussion, content, taskGroup, relationships, declarations, parameters, sampleDownload, row
20+
case mentions, discussion, content, taskGroup, relationships, declarations, parameters, sampleDownload, row
2121

2222
// Rest symbol sections
2323
case restParameters, restResponses, restBody, restEndpoint, properties
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
struct MentionsSectionTranslator: RenderSectionTranslator {
12+
var symbolReference: ResolvedTopicReference
13+
init(referencingSymbol symbolReference: ResolvedTopicReference) {
14+
self.symbolReference = symbolReference
15+
}
16+
17+
func translateSection(for symbol: Symbol, renderNode: inout RenderNode, renderNodeTranslator: inout RenderNodeTranslator) -> VariantCollection<CodableContentSection?>? {
18+
guard FeatureFlags.current.isExperimentalMentionedInEnabled else {
19+
return nil
20+
}
21+
22+
let mentions = renderNodeTranslator.context.articleSymbolMentions.articlesMentioning(symbolReference)
23+
guard !mentions.isEmpty else {
24+
return nil
25+
}
26+
27+
renderNodeTranslator.collectedTopicReferences.append(contentsOf: mentions)
28+
29+
let section = MentionsRenderSection(mentions: mentions.map { $0.url })
30+
return VariantCollection(defaultValue: CodableContentSection(section))
31+
}
32+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
13+
public struct MentionsRenderSection: RenderSection, Codable, Equatable {
14+
public var kind: RenderSectionKind = .mentions
15+
public var mentions: [URL]
16+
17+
public init(mentions: [URL]) {
18+
self.mentions = mentions
19+
}
20+
}
21+
22+
extension MentionsRenderSection: TextIndexing {
23+
public var headings: [String] {
24+
return []
25+
}
26+
27+
public func rawIndexableTextContent(references: [String : RenderReference]) -> String {
28+
return ""
29+
}
30+
}
31+
32+
// Diffable conformance
33+
extension MentionsRenderSection: RenderJSONDiffable {
34+
/// Returns the differences between this MentionsRenderSection and the given one.
35+
func difference(from other: MentionsRenderSection, at path: CodablePath) -> JSONPatchDifferences {
36+
var diffBuilder = DifferenceBuilder(current: self, other: other, basePath: path)
37+
38+
diffBuilder.addDifferences(atKeyPath: \.mentions, forKey: CodingKeys.mentions)
39+
40+
return diffBuilder.differences
41+
}
42+
43+
/// Returns if this DeclarationsRenderSection is similar enough to the given one.
44+
func isSimilar(to other: MentionsRenderSection) -> Bool {
45+
return self.mentions == other.mentions
46+
}
47+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Markdown
12+
13+
/// An index that describes which articles mention symbols.
14+
///
15+
/// When an article mentions a symbol from a module registered with the
16+
/// documentation context, the mention is recorded in this data structure.
17+
/// This is ultimately used to render a "mentioned in" section in symbol documentation.
18+
///
19+
/// This type should only record article -> symbol links, as the "mentioned in" section
20+
/// is for directing readers to explanatory articles from the API reference.
21+
struct ArticleSymbolMentions {
22+
/// A count of symbol mentions.
23+
var mentions: [ResolvedTopicReference: [ResolvedTopicReference: Int]] = [:]
24+
25+
/// Record a symbol mention within an article.
26+
mutating func article(_ article: ResolvedTopicReference, didMention symbol: ResolvedTopicReference, weight: Int) {
27+
mentions[symbol, default: [:]][article, default: 0] += 1 * weight
28+
}
29+
30+
/// The list of articles mentioning a symbol, from most frequent to least frequent.
31+
func articlesMentioning(_ symbol: ResolvedTopicReference) -> [ResolvedTopicReference] {
32+
// Mentions are sorted on demand based on the number of mentions.
33+
// This could change in the future.
34+
return mentions[symbol, default: [:]].sorted {
35+
$0.value > $1.value
36+
}
37+
.map { $0.key }
38+
}
39+
}
40+
41+
struct SymbolLinkCollector: MarkupWalker {
42+
var context: DocumentationContext
43+
var article: ResolvedTopicReference
44+
var baseWeight: Int
45+
46+
func visitSymbolLink(_ symbolLink: SymbolLink) {
47+
if let destination = symbolLink.destination,
48+
let symbol = context.referenceIndex[destination] {
49+
context.articleSymbolMentions.article(article, didMention: symbol, weight: baseWeight)
50+
}
51+
}
52+
}

Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"openapi": "3.0.0",
33
"info": {
44
"description": "Render Node API",
5-
"version": "0.3.0",
5+
"version": "0.4.0",
66
"title": "Render Node API"
77
},
88
"paths": { },
@@ -2442,6 +2442,9 @@
24422442
},
24432443
"DocumentationPrimaryRenderSection": {
24442444
"oneOf": [
2445+
{
2446+
"$ref": "#/components/schemas/MentionsRenderSection"
2447+
},
24452448
{
24462449
"$ref": "#/components/schemas/DeclarationsRenderSection"
24472450
},
@@ -2477,6 +2480,21 @@
24772480
}
24782481
]
24792482
},
2483+
"MentionsRenderSection": {
2484+
"required": [
2485+
"kind",
2486+
"mentions"
2487+
],
2488+
"type": "object",
2489+
"properties": {
2490+
"mentions": {
2491+
"type": "array",
2492+
"items": {
2493+
"type": "string"
2494+
}
2495+
}
2496+
}
2497+
},
24802498
"DeclarationsRenderSection": {
24812499
"required": [
24822500
"kind",

Sources/SwiftDocC/Utility/FeatureFlags.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ public struct FeatureFlags: Codable {
2727
/// Whether or not experimental support for combining overloaded symbol pages is enabled.
2828
public var isExperimentalOverloadedSymbolPresentationEnabled = false
2929

30+
/// Whether experimental support for automatically rendering links on symbol documentation to articles
31+
/// that mention that symbol.
32+
public var isExperimentalMentionedInEnabled = false
33+
3034
/// Creates a set of feature flags with the given values.
3135
///
3236
/// - Parameters:

Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ extension ConvertAction {
2323
FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled = convert.enableExperimentalDeviceFrameSupport
2424
FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = convert.enableExperimentalLinkHierarchySerialization
2525
FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation
26-
26+
FeatureFlags.current.isExperimentalMentionedInEnabled = convert.enableExperimentalMentionedIn
27+
2728
// If the user-provided a URL for an external link resolver, attempt to
2829
// initialize an `OutOfProcessReferenceResolver` with the provided URL.
2930
if let linkResolverURL = convert.outOfProcessLinkResolverOption.linkResolverExecutableURL {

Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,12 @@ extension Docc {
539539
)
540540
var enableExperimentalOverloadedSymbolPresentation = false
541541

542+
@Flag(
543+
name: .customLong("enable-experimental-mentioned-in"),
544+
help: ArgumentHelp("Render a section on symbol documentation which links to articles that mention that symbol")
545+
)
546+
var enableExperimentalMentionedIn = false
547+
542548
@Flag(help: "Write additional metadata files to the output directory.")
543549
var emitDigest = false
544550

@@ -628,6 +634,13 @@ extension Docc {
628634
set { featureFlags.enableExperimentalOverloadedSymbolPresentation = newValue }
629635
}
630636

637+
/// A user-provided value that is true if the user enables experimental automatically generated "mentioned in"
638+
/// links on symbols.
639+
public var enableExperimentalMentionedIn: Bool {
640+
get { featureFlags.enableExperimentalMentionedIn }
641+
set { featureFlags.enableExperimentalMentionedIn = newValue }
642+
}
643+
631644
/// A user-provided value that is true if additional metadata files should be produced.
632645
///
633646
/// Defaults to false.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2024 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See https://swift.org/LICENSE.txt for license information
8+
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import XCTest
13+
@testable import SwiftDocC
14+
15+
class MentionsRenderSectionTests: XCTestCase {
16+
/// Verify that the Mentioned In section is present when a symbol is mentioned,
17+
/// pointing to the correct article.
18+
func testMentionedInSectionFull() throws {
19+
enableFeatureFlag(\.isExperimentalMentionedInEnabled)
20+
let (bundle, context) = try createMentionedInTestBundle()
21+
let identifier = ResolvedTopicReference(
22+
bundleIdentifier: bundle.identifier,
23+
path: "/documentation/MentionedIn/MyClass",
24+
sourceLanguage: .swift
25+
)
26+
let mentioningArticle = ResolvedTopicReference(
27+
bundleIdentifier: bundle.identifier,
28+
path: "/documentation/MentionedIn/ArticleMentioningSymbol",
29+
sourceLanguage: .swift
30+
)
31+
let source = context.documentURL(for: identifier)
32+
let node = try context.entity(with: identifier)
33+
var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference, source: source)
34+
let renderNode = translator.visit(node.semantic) as! RenderNode
35+
let mentionsSection = try XCTUnwrap(renderNode.primaryContentSections.mapFirst { $0 as? MentionsRenderSection })
36+
XCTAssertEqual(1, mentionsSection.mentions.count)
37+
let soleMention = try XCTUnwrap(mentionsSection.mentions.first)
38+
XCTAssertEqual(mentioningArticle.url, soleMention)
39+
}
40+
41+
/// If there are no qualifying mentions of a symbol, the Mentioned In section should not appear.
42+
func testMentionedInSectionEmpty() throws {
43+
enableFeatureFlag(\.isExperimentalMentionedInEnabled)
44+
let (bundle, context) = try createMentionedInTestBundle()
45+
let identifier = ResolvedTopicReference(
46+
bundleIdentifier: bundle.identifier,
47+
path: "/documentation/MentionedIn/MyClass/myFunction()",
48+
sourceLanguage: .swift
49+
)
50+
let source = context.documentURL(for: identifier)
51+
let node = try context.entity(with: identifier)
52+
var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference, source: source)
53+
let renderNode = translator.visit(node.semantic) as! RenderNode
54+
let mentionsSection = renderNode.primaryContentSections.mapFirst { $0 as? MentionsRenderSection }
55+
XCTAssertNil(mentionsSection)
56+
}
57+
}

Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorSymbolVariantsTests.swift

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -570,14 +570,7 @@ class RenderNodeTranslatorSymbolVariantsTests: XCTestCase {
570570

571571
func testDiscussionSectionVariants() throws {
572572
func discussionSection(in renderNode: RenderNode) throws -> ContentRenderSection {
573-
let discussionSectionIndex = 1
574-
575-
guard renderNode.primaryContentSections.indices.contains(discussionSectionIndex) else {
576-
XCTFail("Missing discussion section")
577-
return ContentRenderSection(kind: .content, content: [], heading: nil)
578-
}
579-
580-
return try XCTUnwrap(renderNode.primaryContentSections[discussionSectionIndex] as? ContentRenderSection)
573+
return try XCTUnwrap(renderNode.primaryContentSections.mapFirst { $0 as? ContentRenderSection })
581574
}
582575

583576
try assertMultiVariantSymbol(

0 commit comments

Comments
 (0)