Skip to content

Commit 7446d1d

Browse files
committed
Add support for standalone articles and tutorials
Adds support for ConvertService to generate documentation for single articles without a top-level root page and for uncurated tutorials. rdar://105374582
1 parent 4b2954c commit 7446d1d

File tree

10 files changed

+220
-19
lines changed

10 files changed

+220
-19
lines changed

Sources/SwiftDocC/DocumentationService/Convert/ConvertService+DataProvider.swift

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,23 @@ extension ConvertService {
2424
info: DocumentationBundle.Info,
2525
symbolGraphs: [Data],
2626
markupFiles: [Data],
27+
tutorialFiles: [Data],
2728
miscResourceURLs: [URL]
2829
) {
29-
let symbolGraphURLs = symbolGraphs.map { registerFile(contents: $0, isMarkupFile: false) }
30-
let markupFileURLs = markupFiles.map { registerFile(contents: $0, isMarkupFile: true) }
30+
let symbolGraphURLs = symbolGraphs.map { registerFile(contents: $0, pathExtension: nil) }
31+
let markupFileURLs = markupFiles.map { markupFile in
32+
registerFile(
33+
contents: markupFile,
34+
pathExtension:
35+
DocumentationBundleFileTypes.referenceFileExtension
36+
)
37+
} + tutorialFiles.map { tutorialFile in
38+
registerFile(
39+
contents: tutorialFile,
40+
pathExtension:
41+
DocumentationBundleFileTypes.tutorialFileExtension
42+
)
43+
}
3144

3245
bundles.append(
3346
DocumentationBundle(
@@ -39,8 +52,8 @@ extension ConvertService {
3952
)
4053
}
4154

42-
private mutating func registerFile(contents: Data, isMarkupFile: Bool) -> URL {
43-
let url = Self.createURL(isMarkupFile: isMarkupFile)
55+
private mutating func registerFile(contents: Data, pathExtension: String?) -> URL {
56+
let url = Self.createURL(pathExtension: pathExtension)
4457
files[url] = contents
4558
return url
4659
}
@@ -50,11 +63,11 @@ extension ConvertService {
5063
/// The URL this function generates for a resource is not derived from the resource itself, because it doesn't need to be. The
5164
/// ``DocumentationWorkspaceDataProvider`` model revolves around retrieving resources by their URL. In our use
5265
/// case, our resources are not file URLs so we generate a URL for each resource.
53-
static private func createURL(isMarkupFile: Bool) -> URL {
66+
static private func createURL(pathExtension: String? = nil) -> URL {
5467
var url = URL(string: "docc-service:/\(UUID().uuidString)")!
5568

56-
if isMarkupFile {
57-
url.appendPathExtension(DocumentationBundleFileTypes.referenceFileExtension)
69+
if let pathExtension = pathExtension {
70+
url.appendPathExtension(pathExtension)
5871
}
5972

6073
return url

Sources/SwiftDocC/DocumentationService/Convert/ConvertService.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ public struct ConvertService: DocumentationService {
136136
info: request.bundleInfo,
137137
symbolGraphs: request.symbolGraphs,
138138
markupFiles: request.markupFiles,
139+
tutorialFiles: request.tutorialFiles,
139140
miscResourceURLs: request.miscResourceURLs
140141
)
141142

@@ -145,6 +146,10 @@ public struct ConvertService: DocumentationService {
145146
let context = try DocumentationContext(dataProvider: workspace)
146147
context.knownDisambiguatedSymbolPathComponents = request.knownDisambiguatedSymbolPathComponents
147148

149+
// Enable support for generating documentation for standalone articles and tutorials.
150+
context.allowsRegisteringArticlesWithoutTechnologyRoot = true
151+
context.allowsRegisteringUncuratedTutorials = true
152+
148153
if let linkResolvingServer = linkResolvingServer {
149154
let resolver = try OutOfProcessReferenceResolver(
150155
bundleIdentifier: request.bundleInfo.identifier,

Sources/SwiftDocC/DocumentationService/Models/Services/Convert/ConvertRequest.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,16 @@ public struct ConvertRequest: Codable {
105105
/// - ``DocumentationBundle/symbolGraphURLs``
106106
public var symbolGraphs: [Data]
107107

108-
/// The markup file data included in the documentation bundle to convert.
108+
/// The article and documentation extension file data included in the documentation bundle to convert.
109109
///
110110
/// ## See Also
111111
/// - ``DocumentationBundle/markupURLs``
112112
public var markupFiles: [Data]
113113

114+
115+
/// The tutorial file data included in the documentation bundle to convert.
116+
public var tutorialFiles: [Data]
117+
114118
/// The on-disk resources in the documentation bundle to convert.
115119
///
116120
/// ## See Also
@@ -153,6 +157,7 @@ public struct ConvertRequest: Codable {
153157
self.symbolGraphs = symbolGraphs
154158
self.knownDisambiguatedSymbolPathComponents = knownDisambiguatedSymbolPathComponents
155159
self.markupFiles = markupFiles
160+
self.tutorialFiles = []
156161
self.miscResourceURLs = miscResourceURLs
157162
self.featureFlags = FeatureFlags()
158163

@@ -174,7 +179,8 @@ public struct ConvertRequest: Codable {
174179
/// - symbolGraphs: The symbols graph data included in the documentation bundle to convert.
175180
/// - knownDisambiguatedSymbolPathComponents: The mapping of external symbol identifiers to
176181
/// known disambiguated symbol path components.
177-
/// - markupFiles: The markup file data included in the documentation bundle to convert.
182+
/// - markupFiles: The article and documentation extension file data included in the documentation bundle to convert.
183+
/// - tutorialFiles: The tutorial file data included in the documentation bundle to convert.
178184
/// - miscResourceURLs: The on-disk resources in the documentation bundle to convert.
179185
public init(
180186
bundleInfo: DocumentationBundle.Info,
@@ -186,6 +192,7 @@ public struct ConvertRequest: Codable {
186192
symbolGraphs: [Data],
187193
knownDisambiguatedSymbolPathComponents: [String: [String]]? = nil,
188194
markupFiles: [Data],
195+
tutorialFiles: [Data] = [],
189196
miscResourceURLs: [URL]
190197
) {
191198
self.externalIDsToConvert = externalIDsToConvert
@@ -195,6 +202,7 @@ public struct ConvertRequest: Codable {
195202
self.symbolGraphs = symbolGraphs
196203
self.knownDisambiguatedSymbolPathComponents = knownDisambiguatedSymbolPathComponents
197204
self.markupFiles = markupFiles
205+
self.tutorialFiles = tutorialFiles
198206
self.miscResourceURLs = miscResourceURLs
199207
self.bundleInfo = bundleInfo
200208
self.featureFlags = featureFlags

Sources/SwiftDocC/Infrastructure/DocumentationBundleFileTypes.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public enum DocumentationBundleFileTypes {
2121
return url.pathExtension.lowercased() == referenceFileExtension
2222
}
2323

24-
private static let tutorialFileExtension = "tutorial"
24+
static let tutorialFileExtension = "tutorial"
2525
/// Checks if a file is a tutorial file.
2626
/// - Parameter url: The file to check.
2727
/// - Returns: Whether or not the file at `url` is a tutorial file.

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
139139
}
140140
}
141141
}
142+
143+
/// Controls whether bundle registration should allow registering articles when no technology root is defined.
144+
///
145+
/// Set this property to `true` to enable registering documentation for standalone articles,
146+
/// for example when using ``ConvertService``.
147+
var allowsRegisteringArticlesWithoutTechnologyRoot: Bool = false
148+
149+
/// Controls whether tutorials that aren't curated in a tutorials overview page are registered and translated.
150+
///
151+
/// Set this property to `true` to enable registering documentation for standalone tutorials,
152+
/// for example when ``ConvertService``.
153+
var allowsRegisteringUncuratedTutorials: Bool = false
154+
142155
/// The set of all manually curated references if `shouldStoreManuallyCuratedReferences` was true at the time of processing and has remained `true` since.. Nil if curation has not been processed yet.
143156
public private(set) var manuallyCuratedReferences: Set<ResolvedTopicReference>?
144157

@@ -2133,7 +2146,7 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate {
21332146

21342147
// Articles that will be automatically curated can be resolved but they need to be pre registered before resolving links.
21352148
let rootNodeForAutomaticCuration = soleRootModuleReference.flatMap(topicGraph.nodeWithReference(_:))
2136-
if rootNodeForAutomaticCuration != nil {
2149+
if allowsRegisteringArticlesWithoutTechnologyRoot || rootNodeForAutomaticCuration != nil {
21372150
otherArticles = registerArticles(otherArticles, in: bundle)
21382151
try shouldContinueRegistration()
21392152
}

Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ public struct RenderNodeTranslator: SemanticVisitor {
4545
/// Whether the documentation converter should include access level information for symbols.
4646
var shouldEmitSymbolAccessLevels: Bool
4747

48+
/// Whether tutorials that are not curated in a tutorials overview should be translated.
49+
var shouldRenderUncuratedTutorials: Bool = false
50+
4851
/// The source repository where the documentation's sources are hosted.
4952
var sourceRepository: SourceRepository?
5053

@@ -116,26 +119,25 @@ public struct RenderNodeTranslator: SemanticVisitor {
116119
var node = RenderNode(identifier: identifier, kind: .tutorial)
117120

118121
var hierarchyTranslator = RenderHierarchyTranslator(context: context, bundle: bundle)
119-
guard let hierarchy = hierarchyTranslator.visitTechnologyNode(identifier) else {
122+
123+
if let hierarchy = hierarchyTranslator.visitTechnologyNode(identifier) {
124+
let technology = try! context.entity(with: hierarchy.technology).semantic as! Technology
125+
node.hierarchy = hierarchy.hierarchy
126+
node.metadata.category = technology.name
127+
node.metadata.categoryPathComponent = hierarchy.technology.url.lastPathComponent
128+
} else if !context.allowsRegisteringArticlesWithoutTechnologyRoot {
120129
// This tutorial is not curated, so we don't generate a render node.
121130
// We've warned about this during semantic analysis.
122131
return nil
123132
}
124133

125-
let technology = try! context.entity(with: hierarchy.technology).semantic as! Technology
126-
127134
node.metadata.title = tutorial.intro.title
128135
node.metadata.role = contentRenderer.role(for: .tutorial).rawValue
129136

130137
collectedTopicReferences.append(contentsOf: hierarchyTranslator.collectedTopicReferences)
131138

132-
node.hierarchy = hierarchy.hierarchy
133-
node.metadata.category = technology.name
134-
135139
let documentationNode = try! context.entity(with: identifier)
136140
node.variants = variants(for: documentationNode)
137-
138-
node.metadata.categoryPathComponent = hierarchy.technology.url.lastPathComponent
139141

140142
var intro = visitIntro(tutorial.intro) as! IntroRenderSection
141143
intro.estimatedTimeInMinutes = tutorial.durationMinutes

Tests/SwiftDocCTests/DocumentationService/ConvertService/ConvertServiceTests.swift

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,145 @@ class ConvertServiceTests: XCTestCase {
524524
}
525525
}
526526

527+
func testConvertSingleArticlePage() throws {
528+
let articleFile = Bundle.module.url(
529+
forResource: "StandaloneArticle",
530+
withExtension: "md",
531+
subdirectory: "Test Resources"
532+
)!
533+
534+
let article = try Data(contentsOf: articleFile)
535+
536+
let request = ConvertRequest(
537+
bundleInfo: testBundleInfo,
538+
externalIDsToConvert: nil,
539+
documentPathsToConvert: nil,
540+
symbolGraphs: [],
541+
knownDisambiguatedSymbolPathComponents: nil,
542+
markupFiles: [article],
543+
miscResourceURLs: []
544+
)
545+
546+
try processAndAssert(request: request) { message in
547+
XCTAssertEqual(message.type, "convert-response")
548+
XCTAssertEqual(message.identifier, "test-identifier-response")
549+
550+
let response = try JSONDecoder().decode(
551+
ConvertResponse.self, from: XCTUnwrap(message.payload)
552+
)
553+
554+
XCTAssertEqual(response.renderNodes.count, 1)
555+
let data = try XCTUnwrap(response.renderNodes.first)
556+
let renderNode = try JSONDecoder().decode(RenderNode.self, from: data)
557+
558+
XCTAssertEqual(
559+
renderNode.metadata.externalID,
560+
nil
561+
)
562+
563+
XCTAssertEqual(renderNode.kind, .article)
564+
565+
XCTAssertEqual(renderNode.abstract?.count, 1)
566+
567+
XCTAssertEqual(
568+
renderNode.abstract?.first,
569+
.text("An article abstract.")
570+
)
571+
}
572+
}
573+
574+
func testConvertSingleTutorial() throws {
575+
let tutorialFile = Bundle.module.url(
576+
forResource: "StandaloneTutorial",
577+
withExtension: "tutorial",
578+
subdirectory: "Test Resources"
579+
)!
580+
581+
let tutorial = try Data(contentsOf: tutorialFile)
582+
583+
let request = ConvertRequest(
584+
bundleInfo: testBundleInfo,
585+
externalIDsToConvert: nil,
586+
documentPathsToConvert: nil,
587+
symbolGraphs: [],
588+
knownDisambiguatedSymbolPathComponents: nil,
589+
markupFiles: [],
590+
tutorialFiles: [tutorial],
591+
miscResourceURLs: []
592+
)
593+
594+
try processAndAssert(request: request) { message in
595+
XCTAssertEqual(message.type, "convert-response")
596+
XCTAssertEqual(message.identifier, "test-identifier-response")
597+
598+
let response = try JSONDecoder().decode(
599+
ConvertResponse.self, from: XCTUnwrap(message.payload)
600+
)
601+
602+
XCTAssertEqual(response.renderNodes.count, 1)
603+
let data = try XCTUnwrap(response.renderNodes.first)
604+
let renderNode = try JSONDecoder().decode(RenderNode.self, from: data)
605+
606+
XCTAssertEqual(
607+
renderNode.metadata.externalID,
608+
nil
609+
)
610+
611+
XCTAssertEqual(renderNode.kind, .tutorial)
612+
613+
XCTAssertEqual(
614+
renderNode.metadata.title,
615+
"Standalone Tutorial"
616+
)
617+
}
618+
}
619+
620+
func testConvertSingleTutorialOverview() throws {
621+
let tutorialOverviewFile = Bundle.module.url(
622+
forResource: "StandaloneTutorialOverview",
623+
withExtension: "tutorial",
624+
subdirectory: "Test Resources"
625+
)!
626+
627+
let tutorialOverview = try Data(contentsOf: tutorialOverviewFile)
628+
629+
let request = ConvertRequest(
630+
bundleInfo: testBundleInfo,
631+
externalIDsToConvert: nil,
632+
documentPathsToConvert: nil,
633+
symbolGraphs: [],
634+
knownDisambiguatedSymbolPathComponents: nil,
635+
markupFiles: [],
636+
tutorialFiles: [tutorialOverview],
637+
miscResourceURLs: []
638+
)
639+
640+
try processAndAssert(request: request) { message in
641+
XCTAssertEqual(message.type, "convert-response")
642+
XCTAssertEqual(message.identifier, "test-identifier-response")
643+
644+
let response = try JSONDecoder().decode(
645+
ConvertResponse.self, from: XCTUnwrap(message.payload)
646+
)
647+
648+
XCTAssertEqual(response.renderNodes.count, 1)
649+
let data = try XCTUnwrap(response.renderNodes.first)
650+
let renderNode = try JSONDecoder().decode(RenderNode.self, from: data)
651+
652+
XCTAssertEqual(
653+
renderNode.metadata.externalID,
654+
nil
655+
)
656+
657+
XCTAssertEqual(renderNode.kind, .overview)
658+
659+
XCTAssertEqual(
660+
renderNode.metadata.title,
661+
"Standalone Tutorial Overview"
662+
)
663+
}
664+
}
665+
527666
func processAndAssertResponseContents(
528667
expectedRenderNodePaths: [String],
529668
includesRenderReferenceStore: Bool,
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# My Article
2+
3+
An article abstract.
4+
5+
This is an overview of the article.
6+
7+
<!-- Copyright (c) 2023 Apple Inc and the Swift Project authors. All Rights Reserved. -->
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@Tutorial {
2+
@Intro(title: "Standalone Tutorial") {
3+
4+
This is the tutorial abstract.
5+
}
6+
}
7+
8+
<!-- Copyright (c) 2023 Apple Inc and the Swift Project authors. All Rights Reserved. -->
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@Tutorials(name: "Standalone Tutorial Overview") {
2+
@Intro(title: "Standalone Tutorial Overview") {
3+
}
4+
}
5+
6+
<!-- Copyright (c) 2023 Apple Inc and the Swift Project authors. All Rights Reserved. -->

0 commit comments

Comments
 (0)