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
141 changes: 95 additions & 46 deletions Sources/SwiftDocC/Infrastructure/DocumentationContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,13 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
}

/// The root module nodes of the Topic Graph.
public var rootModules: [ResolvedTopicReference] {
return topicGraph.nodes.values.compactMap { node in
guard node.kind == .module,
!onlyHasSnippetRelatedChildren(for: node.reference) else {
return nil
}
return node.reference
}
///
/// This property is initialized during the registration of a documentation bundle.
public private(set) var rootModules: [ResolvedTopicReference]!

/// The topic reference of the root module, if it's the only registered module.
var soleRootModuleReference: ResolvedTopicReference? {
rootModules.count == 1 ? rootModules.first : nil
}

/// Map of document URLs to topic references.
Expand Down Expand Up @@ -1624,9 +1623,23 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
/// - Parameters:
/// - articles: Articles to register with the documentation cache.
/// - bundle: The bundle containing the articles.
private func registerArticles(_ articles: DocumentationContext.Articles, in bundle: DocumentationBundle) {
for article in articles {
guard let (documentation, title) = DocumentationContext.documentationNodeAndTitle(for: article, kind: .article, in: bundle) else { continue }
/// - Returns: The articles that were registered, with their topic graph node updated to what's been added to the topic graph.
private func registerArticles(
_ articles: DocumentationContext.Articles,
in bundle: DocumentationBundle
) -> DocumentationContext.Articles {
articles.map { article in
guard let (documentation, title) = DocumentationContext.documentationNodeAndTitle(
for: article,

// Articles are available in the same languages the only root module is available in. If there is more
// than one module, we cannot determine what languages it's available in and default to Swift.
availableSourceLanguages: soleRootModuleReference?.sourceLanguages,
kind: .article,
in: bundle
) else {
return article
}
let reference = documentation.reference

documentationCache[reference] = documentation
Expand All @@ -1638,6 +1651,11 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
for anchor in documentation.anchorSections {
nodeAnchorSections[anchor.reference] = anchor
}

var article = article
// Update the article's topic graph node with the one we just added to the topic graph.
article.topicGraphNode = graphNode
return article
}
}

Expand All @@ -1648,16 +1666,45 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
/// - kind: The kind that should be used to create the returned documentation node.
/// - bundle: The documentation bundle this article belongs to.
/// - Returns: A documentation node and title for the given article semantic result.
static func documentationNodeAndTitle(for article: DocumentationContext.SemanticResult<Article>, kind: DocumentationNode.Kind, in bundle: DocumentationBundle) -> (node: DocumentationNode, title: String)? {
static func documentationNodeAndTitle(
for article: DocumentationContext.SemanticResult<Article>,
availableSourceLanguages: Set<SourceLanguage>? = nil,
kind: DocumentationNode.Kind,
in bundle: DocumentationBundle
) -> (node: DocumentationNode, title: String)? {
guard let articleMarkup = article.value.markup else {
return nil
}

let path = NodeURLGenerator.pathForSemantic(article.value, source: article.source, bundle: bundle)
let reference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: path, sourceLanguage: .swift)

// If available source languages are provided and it contains Swift, use Swift as the default language of
// the article.
let defaultSourceLanguage = availableSourceLanguages.map { availableSourceLanguages in
if availableSourceLanguages.contains(.swift) {
return .swift
} else {
return availableSourceLanguages.first ?? .swift
}
} ?? SourceLanguage.swift

let reference = ResolvedTopicReference(
bundleIdentifier: bundle.identifier,
path: path,
sourceLanguages: availableSourceLanguages ?? [.swift]
)

let title = article.topicGraphNode.title

let documentationNode = DocumentationNode(reference: reference, kind: kind, sourceLanguage: .swift, name: .conceptual(title: title), markup: articleMarkup, semantic: article.value)
let documentationNode = DocumentationNode(
reference: reference,
kind: kind,
sourceLanguage: defaultSourceLanguage,
availableSourceLanguages: availableSourceLanguages,
name: .conceptual(title: title),
markup: articleMarkup,
semantic: article.value
)

return (documentationNode, title)
}
Expand Down Expand Up @@ -1693,11 +1740,29 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
}

let articleReferences = autoCuratedArticles.map(\.topicGraphNode.reference)
let autoArticlesSection = AutomaticTaskGroupSection(title: "Articles", references: articleReferences, renderPositionPreference: .top)

func createAutomaticTaskGroupSection(references: [ResolvedTopicReference]) -> AutomaticTaskGroupSection {
AutomaticTaskGroupSection(
title: "Articles",
references: references,
renderPositionPreference: .top
)
}

let node = try entity(with: rootNode.reference)
if var taskGroupProviding = node.semantic as? AutomaticTaskGroupsProviding {
taskGroupProviding.automaticTaskGroups = [autoArticlesSection]

// If the node we're automatically curating the article under is a symbol, automatically curate the article
// for each language it's available in.
if let symbol = node.semantic as? Symbol {
for sourceLanguage in node.availableSourceLanguages {
symbol.automaticTaskGroupsVariants[
.init(interfaceLanguage: sourceLanguage.id)
] = [createAutomaticTaskGroupSection(references: articleReferences)]
}
} else if var taskGroupProviding = node.semantic as? AutomaticTaskGroupsProviding {
taskGroupProviding.automaticTaskGroups = [
createAutomaticTaskGroupSection(references: articleReferences)
]
}

return articleReferences
Expand Down Expand Up @@ -1779,7 +1844,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {

// All discovery went well, process the inputs.
let (technologies, tutorials, tutorialArticles, allArticles) = result
let (otherArticles, rootPageArticles) = splitArticles(allArticles)
var (otherArticles, rootPageArticles) = splitArticles(allArticles)

let rootPages = registerRootPages(from: rootPageArticles, in: bundle)
let (moduleReferences, symbolsURLHierarchy) = try registerSymbols(from: bundle, symbolGraphLoader: symbolGraphLoader)
Expand All @@ -1788,10 +1853,20 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {

try shouldContinueRegistration()

// Keep track of the root modules registered from symbol graph files, we'll need them to automatically
// curate articles.
rootModules = topicGraph.nodes.values.compactMap { node in
guard node.kind == .module,
!onlyHasSnippetRelatedChildren(for: node.reference) else {
Copy link
Contributor

Choose a reason for hiding this comment

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

This snippet check is a little confusing to me. Maybe we should consider a node.isSnippetModule property at some point?

It almost seems like we should have a separate kind .snippetModule if we're going to need to special-case these.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good to me. This logic was just moved in this PR, but we should consider revisiting this in the future.

return nil
}
return node.reference
}

// Articles that will be automatically curated can be resolved but they need to be pre registered before resolving links.
let rootNodeForAutomaticCuration = rootModules.count == 1 ? rootModules.first.flatMap(topicGraph.nodeWithReference) : nil
let rootNodeForAutomaticCuration = soleRootModuleReference.flatMap(topicGraph.nodeWithReference(_:))
if rootNodeForAutomaticCuration != nil {
registerArticles(otherArticles, in: bundle)
otherArticles = registerArticles(otherArticles, in: bundle)
try shouldContinueRegistration()
}

Expand Down Expand Up @@ -2550,32 +2625,6 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
return results
}

/// Finds the presentation language the symbol is curated under by walking
/// the documentation graph upwards and finding a parent that has an interface language.
/// Will return `nil` if no parent with assigned interface language is found
/// (e.g. in a tutorials only bundle).
func interfaceLanguageFor(_ reference: ResolvedTopicReference) throws -> SourceLanguage? {
let node = try entity(with: reference)
guard !(node.semantic is Symbol) else {
// For symbols just return the source language
return node.sourceLanguage
}

// `pathsTo()` returns the canonical path first
guard let canonical = pathsTo(reference).first else {
// Uncurated symbol, if not expected a warning will be emitted elsewhere
return nil
}

// Return the language of the first symbol entity
return canonical.mapFirst { reference -> SourceLanguage? in
guard let node = try? entity(with: reference), node.semantic is Symbol else {
return nil
}
return node.sourceLanguage
}
}

func dumpGraph() -> String {
return topicGraph.nodes.values
.filter { parents(of: $0.reference).isEmpty }
Expand Down
53 changes: 24 additions & 29 deletions Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -586,16 +586,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
collectedTopicReferences.append(contentsOf: hierarchyTranslator.collectedTopicReferences)
node.hierarchy = hierarchy

// Find the language of the symbol that curated the article in the graph
// and use it as the interface language for that article.
if let language = try! context.interfaceLanguageFor(identifier)?.id {
let generator = PresentationURLGenerator(context: context, baseURL: bundle.baseURL)
node.variants = [
.init(traits: [.interfaceLanguage(language)], paths: [
generator.presentationURLForReference(identifier).path
])
]
}
node.variants = variants(for: documentationNode)

if let abstract = article.abstractSection,
let abstractContent = visitMarkup(abstract.content) as? [RenderInlineContent] {
Expand Down Expand Up @@ -960,25 +951,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
node.metadata.fragmentsVariants = contentRenderer.subHeadingFragments(for: documentationNode)
node.metadata.navigatorTitleVariants = contentRenderer.navigatorFragments(for: documentationNode)

let generator = PresentationURLGenerator(context: context, baseURL: bundle.baseURL)

node.variants = documentationNode.availableSourceLanguages
.sorted(by: { language1, language2 in
// Emit Swift first, then alphabetically.
switch (language1, language2) {
case (.swift, _): return true
case (_, .swift): return false
default: return language1.id < language2.id
}
})
.map { sourceLanguage in
RenderNode.Variant(
traits: [.interfaceLanguage(sourceLanguage.id)],
paths: [
generator.presentationURLForReference(identifier).path
]
)
}
node.variants = variants(for: documentationNode)

collectedTopicReferences.append(identifier)

Expand Down Expand Up @@ -1406,6 +1379,28 @@ public struct RenderNodeTranslator: SemanticVisitor {
}
}

private func variants(for documentationNode: DocumentationNode) -> [RenderNode.Variant] {
let generator = PresentationURLGenerator(context: context, baseURL: bundle.baseURL)

return documentationNode.availableSourceLanguages
.sorted(by: { language1, language2 in
// Emit Swift first, then alphabetically.
switch (language1, language2) {
case (.swift, _): return true
case (_, .swift): return false
default: return language1.id < language2.id
}
})
.map { sourceLanguage in
RenderNode.Variant(
traits: [.interfaceLanguage(sourceLanguage.id)],
paths: [
generator.presentationURLForReference(identifier).path
]
)
}
}

init(
context: DocumentationContext,
bundle: DocumentationBundle,
Expand Down
96 changes: 85 additions & 11 deletions Tests/SwiftDocCTests/Infrastructure/DocumentationContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1339,17 +1339,6 @@ let expected = """
let canonicalPathFFF = try XCTUnwrap(context.pathsTo(fffNode.reference).first)
XCTAssertEqual(["/documentation/MyKit"], canonicalPathFFF.map({ $0.path }))
}

func testLanguageForNode() throws {
let workspace = DocumentationWorkspace()
let context = try DocumentationContext(dataProvider: workspace)
let bundle = try testBundle(named: "TestBundle")
let dataProvider = PrebuiltLocalFileSystemDataProvider(bundles: [bundle])
try workspace.registerProvider(dataProvider)
let articleReference = ResolvedTopicReference(bundleIdentifier: bundle.identifier, path: "/documentation/Test-Bundle/article", sourceLanguage: .swift)
let articleLanguage = try context.interfaceLanguageFor(articleReference)
XCTAssertEqual(articleLanguage, SourceLanguage.swift)
}

// Verify that a symbol that has no parents in the symbol graph is automatically curated under the module node.
func testRootSymbolsAreCureatedInModule() throws {
Expand Down Expand Up @@ -3029,6 +3018,91 @@ let expected = """
"Expected the symbol instances in the documentationCache and symbolIndex dictionaries to be the same"
)
}

func assertArticleAvailableSourceLanguages(
moduleAvailableLanguages: Set<SourceLanguage>,
expectedArticleDefaultLanguage: SourceLanguage,
file: StaticString = #file,
line: UInt = #line
) throws {
precondition(
moduleAvailableLanguages.allSatisfy { [.swift, .objectiveC].contains($0) },
"moduleAvailableLanguages can only contain Swift and Objective-C as languages."
)

let (_, _, context) = try testBundleAndContext(copying: "MixedLanguageFramework") { url in
try """
# MyArticle

The framework this article is documenting is available in the following languages: \
\(moduleAvailableLanguages.map(\.name).joined(separator: ",")).
""".write(to: url.appendingPathComponent("myarticle.md"), atomically: true, encoding: .utf8)

func removeSymbolGraph(compiler: String) throws {
try FileManager.default.removeItem(
at: url.appendingPathComponent("symbol-graphs").appendingPathComponent(compiler)
)
}

if !moduleAvailableLanguages.contains(.swift) {
try removeSymbolGraph(compiler: "swift")
}

if !moduleAvailableLanguages.contains(.objectiveC) {
try removeSymbolGraph(compiler: "clang")
}
}

let articleNode = try XCTUnwrap(
context.documentationCache.first {
$0.key.path == "/documentation/MixedLanguageFramework/myarticle"
}?.value,
file: file,
line: line
)

XCTAssertEqual(
articleNode.availableSourceLanguages,
moduleAvailableLanguages,
"Expected the article's source languages to have inherited from the module's available source languages.",
file: file,
line: line
)

XCTAssertEqual(
articleNode.sourceLanguage,
expectedArticleDefaultLanguage,
file: file,
line: line
)
}

func testArticleAvailableSourceLanguagesIsSwiftInSwiftModule() throws {
enableFeatureFlag(\.isExperimentalObjectiveCSupportEnabled)

try assertArticleAvailableSourceLanguages(
moduleAvailableLanguages: [.swift],
expectedArticleDefaultLanguage: .swift
)
}

func testArticleAvailableSourceLanguagesIsMixedLanguageInMixedLanguageModule() throws {
enableFeatureFlag(\.isExperimentalObjectiveCSupportEnabled)

try assertArticleAvailableSourceLanguages(
moduleAvailableLanguages: [.swift, .objectiveC],
expectedArticleDefaultLanguage: .swift
)
}

func testArticleAvailableSourceLanguagesIsObjectiveCInObjectiveCModule() throws {
enableFeatureFlag(\.isExperimentalObjectiveCSupportEnabled)

try assertArticleAvailableSourceLanguages(
moduleAvailableLanguages: [.objectiveC],
expectedArticleDefaultLanguage: .objectiveC
)
}
}

func assertEqualDumps(_ lhs: String, _ rhs: String, file: StaticString = #file, line: UInt = #line) {
Expand Down
Loading