Skip to content

Commit 3eca64f

Browse files
authored
Synthesize a minimal landing page for combined archives (#1011)
* Synthesize a minimal landing page for combined archives rdar://79160385 * Handle a missing "references" section in the to-be-merged root pages * Code review feedback: Remove enum case that's not used yet * Code review feedback: Add CLI argument to customize synthesized landing page topic style * Code review feedback: Hide not-yet-supported merge CLI option
1 parent 610d732 commit 3eca64f

File tree

9 files changed

+482
-18
lines changed

9 files changed

+482
-18
lines changed

Sources/SwiftDocC/Indexing/RenderIndexJSON/RenderIndex.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ public struct RenderIndex: Codable, Equatable {
8282
includedArchiveIdentifiers.append(contentsOf: other.includedArchiveIdentifiers)
8383
}
8484

85+
/// Insert a root node with a given name for each interface language and move the previous root node(s) under the new root node.
86+
/// - Parameter named: The name of the new root node
87+
public mutating func insertRoot(named: String) {
88+
for (languageID, nodes) in interfaceLanguages {
89+
let root = Node(title: named, path: "/documentation", pageType: .framework, isDeprecated: false, children: nodes, icon: nil)
90+
interfaceLanguages[languageID] = [root]
91+
}
92+
}
93+
8594
enum MergeError: DescribedError {
8695
case referenceCollision(String)
8796

Sources/SwiftDocC/Utility/FoundationExtensions/String+Whitespace.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ extension String {
1717
///
1818
/// - Parameter separator: The string to replace contiguous sequences of whitespace and punctuation with.
1919
/// - Returns: A new string with all whitespace and punctuation replaced with a given separator.
20-
func replacingWhitespaceAndPunctuation(with separator: String) -> String {
20+
package func replacingWhitespaceAndPunctuation(with separator: String) -> String {
2121
let charactersToStrip = CharacterSet.whitespaces.union(.punctuationCharacters)
2222
return components(separatedBy: charactersToStrip).filter({ !$0.isEmpty }).joined(separator: separator)
2323
}

Sources/SwiftDocCTestUtilities/FilesAndFolders.swift

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import Foundation
1212
import XCTest
13+
import SwiftDocC
1314

1415
/*
1516
This file contains API for working with folder hierarchies, and is extensible to allow for testing
@@ -269,6 +270,7 @@ extension Folder {
269270
/// - Note: If there are more than one first path component in the provided paths, the return value will contain more than one element.
270271
public static func makeStructure(
271272
filePaths: [String],
273+
renderNodeReferencePrefix: String? = nil,
272274
isEmptyDirectoryCheck: (String) -> Bool = { _ in false }
273275
) -> [File] {
274276
guard !filePaths.isEmpty else {
@@ -286,7 +288,7 @@ extension Folder {
286288
return grouped.map { pathComponent, remaining in
287289
let absolutePath = "\(accumulatedBasePath)/\(pathComponent)"
288290
if remaining == [[]] && !isEmptyDirectoryCheck(absolutePath) {
289-
return TextFile(name: pathComponent, utf8Content: "")
291+
return JSONFile(name: pathComponent, content: makeMinimalTestRenderNode(path: (renderNodeReferencePrefix ?? "") + absolutePath))
290292
} else {
291293
return Folder(name: pathComponent, content: _makeStructure(paths: remaining.filter { !$0.isEmpty }, accumulatedBasePath: absolutePath))
292294
}
@@ -302,6 +304,25 @@ extension Folder {
302304
}
303305
}
304306

307+
private func makeMinimalTestRenderNode(path: String) -> RenderNode {
308+
let reference = ResolvedTopicReference(bundleIdentifier: "org.swift.test", path: path, sourceLanguage: .swift)
309+
let rawReference = reference.url.absoluteString
310+
let title = path.components(separatedBy: "/").last ?? path
311+
312+
var renderNode = RenderNode(identifier: reference, kind: .article)
313+
renderNode.metadata.title = title
314+
renderNode.references = [
315+
rawReference: TopicRenderReference(
316+
identifier: RenderReferenceIdentifier(rawReference),
317+
title: title,
318+
abstract: [],
319+
url: reference.path,
320+
kind: .article
321+
)
322+
]
323+
return renderNode
324+
}
325+
305326
/// A node in a tree structure that can be printed into a visual representation for debugging.
306327
private struct DumpableNode {
307328
var name: String
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
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 SwiftDocC
13+
14+
extension MergeAction {
15+
struct RootRenderReferences {
16+
var documentation, tutorials: [TopicRenderReference]
17+
18+
fileprivate var all: [TopicRenderReference] {
19+
documentation + tutorials
20+
}
21+
var isEmpty: Bool {
22+
documentation.isEmpty && tutorials.isEmpty
23+
}
24+
fileprivate var containsBothKinds: Bool {
25+
!documentation.isEmpty && !tutorials.isEmpty
26+
}
27+
}
28+
29+
func readRootNodeRenderReferencesIn(dataDirectory: URL) throws -> RootRenderReferences {
30+
func inner(url: URL) throws -> [TopicRenderReference] {
31+
try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
32+
.compactMap {
33+
guard $0.pathExtension == "json" else {
34+
return nil
35+
}
36+
37+
let data = try fileManager.contents(of: $0)
38+
return try JSONDecoder().decode(RootNodeRenderReference.self, from: data)
39+
.renderReference
40+
}
41+
.sorted(by: { lhs, rhs in
42+
lhs.title < rhs.title
43+
})
44+
}
45+
46+
return .init(
47+
documentation: try inner(url: dataDirectory.appendingPathComponent("documentation", isDirectory: true)),
48+
tutorials: try inner(url: dataDirectory.appendingPathComponent("tutorials", isDirectory: true))
49+
)
50+
}
51+
52+
func makeSynthesizedLandingPage(
53+
name: String,
54+
reference: ResolvedTopicReference,
55+
roleHeading: String,
56+
topicsStyle: TopicsVisualStyle.Style,
57+
rootRenderReferences: RootRenderReferences
58+
) -> RenderNode {
59+
var renderNode = RenderNode(identifier: reference, kind: .article)
60+
61+
renderNode.topicSectionsStyle = switch topicsStyle {
62+
case .list: .list
63+
case .compactGrid: .compactGrid
64+
case .detailedGrid: .detailedGrid
65+
case .hidden: .hidden
66+
}
67+
renderNode.metadata.title = name
68+
renderNode.metadata.roleHeading = roleHeading
69+
renderNode.metadata.role = "collection"
70+
renderNode.hierarchy = nil
71+
renderNode.sections = []
72+
73+
if rootRenderReferences.containsBothKinds {
74+
// If the combined archive contains both documentation and tutorial content, create separate topic sections for each.
75+
renderNode.topicSections = [
76+
.init(title: "Modules", abstract: nil, discussion: nil, identifiers: rootRenderReferences.documentation.map(\.identifier.identifier)),
77+
.init(title: "Tutorials", abstract: nil, discussion: nil, identifiers: rootRenderReferences.tutorials.map(\.identifier.identifier)),
78+
]
79+
} else {
80+
// Otherwise, create a single unnamed topic section
81+
renderNode.topicSections = [
82+
.init(title: nil, abstract: nil, discussion: nil, identifiers: (rootRenderReferences.all).map(\.identifier.identifier)),
83+
]
84+
}
85+
86+
for renderReference in rootRenderReferences.documentation {
87+
renderNode.references[renderReference.identifier.identifier] = renderReference
88+
}
89+
for renderReference in rootRenderReferences.tutorials {
90+
renderNode.references[renderReference.identifier.identifier] = renderReference
91+
}
92+
93+
return renderNode
94+
}
95+
}
96+
97+
/// A type that decodes the root node reference from a root node's encoded render node data.
98+
private struct RootNodeRenderReference: Decodable {
99+
/// The decoded root node render reference
100+
var renderReference: TopicRenderReference
101+
102+
enum CodingKeys: CodingKey {
103+
// The only render node keys that should be needed
104+
case identifier, references
105+
// Extra render node keys in case we need to re-create the render reference from page content.
106+
case metadata, abstract, kind
107+
}
108+
109+
struct StringCodingKey: CodingKey {
110+
var stringValue: String
111+
init?(stringValue: String) {
112+
self.stringValue = stringValue
113+
}
114+
var intValue: Int? = nil
115+
init?(intValue: Int) {
116+
fatalError("`SparseRenderNode.StringCodingKey` only support string values")
117+
}
118+
}
119+
120+
init(from decoder: any Decoder) throws {
121+
// Instead of decoding the full render node, we only decode the information that's needed.
122+
let container = try decoder.container(keyedBy: CodingKeys.self)
123+
124+
let identifier = try container.decode(ResolvedTopicReference.self, forKey: .identifier)
125+
let rawIdentifier = identifier.url.absoluteString
126+
127+
// Every node should include a reference to the root page.
128+
// For reference documentation, this is because the root appears as a link in the breadcrumbs on every page.
129+
// For tutorials, this is because the tutorial table of content appears as a link in the top navigator.
130+
//
131+
// If the root page has a reference to itself, then that the fastest and easiest way to access the correct topic render reference.
132+
if container.contains(.references) {
133+
let referencesContainer = try container.nestedContainer(keyedBy: StringCodingKey.self, forKey: .references)
134+
if let selfReference = try referencesContainer.decodeIfPresent(TopicRenderReference.self, forKey: .init(stringValue: rawIdentifier)!) {
135+
renderReference = selfReference
136+
return
137+
}
138+
}
139+
140+
// If for some unexpected reason this wasn't true, for example because of an unknown page kind,
141+
// we can create a new topic reference by decoding a little bit more information from the render node.
142+
let metadata = try container.decode(RenderMetadata.self, forKey: .metadata)
143+
144+
renderReference = TopicRenderReference(
145+
identifier: RenderReferenceIdentifier(rawIdentifier),
146+
title: metadata.title ?? identifier.lastPathComponent,
147+
abstract: try container.decodeIfPresent([RenderInlineContent].self, forKey: .abstract) ?? [],
148+
url: identifier.path.lowercased(),
149+
kind: try container.decode(RenderNode.Kind.self, forKey: .kind),
150+
images: metadata.images
151+
)
152+
}
153+
}

Sources/SwiftDocCUtilities/Action/Actions/MergeAction.swift renamed to Sources/SwiftDocCUtilities/Action/Actions/Merge/MergeAction.swift

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,29 @@
1010

1111
import Foundation
1212
import SwiftDocC
13+
import Markdown
1314

1415
/// An action that merges a list of documentation archives into a combined archive.
1516
struct MergeAction: Action {
1617
var archives: [URL]
17-
var landingPageCatalog: URL?
18+
var landingPageInfo: LandingPageInfo
1819
var outputURL: URL
1920
var fileManager: FileManagerProtocol
2021

22+
/// Information about how the merge action should create landing page content for the combined archive
23+
enum LandingPageInfo {
24+
// This enum will have a case for a landing page catalog when we add support for that.
25+
26+
/// The merge action should synthesize a minimal landing page with a given configuration.
27+
case synthesize(SynthesizeConfiguration)
28+
29+
struct SynthesizeConfiguration {
30+
var name: String
31+
var kind: String
32+
var style: TopicsVisualStyle.Style
33+
}
34+
}
35+
2136
mutating func perform(logHandle: LogHandle) throws -> ActionResult {
2237
guard let firstArchive = archives.first else {
2338
// A validation warning should have already been raised in `Docc/Merge/InputAndOutputOptions/validate()`.
@@ -66,17 +81,58 @@ struct MergeAction: Action {
6681
try combinedJSONIndex.merge(renderIndex)
6782
}
6883

69-
try fileManager.createFile(at: jsonIndexURL, contents: RenderJSONEncoder.makeEncoder(emitVariantOverrides: false).encode(combinedJSONIndex))
70-
71-
// TODO: Build landing page from input or synthesize default landing page
84+
switch landingPageInfo {
85+
case .synthesize(let configuration):
86+
try synthesizeLandingPage(configuration, combinedIndex: &combinedJSONIndex, targetURL: targetURL)
87+
}
7288

73-
// TODO: Inactivate external links outside the merged archives
89+
try fileManager.createFile(at: jsonIndexURL, contents: RenderJSONEncoder.makeEncoder(emitVariantOverrides: false).encode(combinedJSONIndex))
7490

7591
try Self.moveOutput(from: targetURL, to: outputURL, fileManager: fileManager)
7692

7793
return ActionResult(didEncounterError: false, outputs: [outputURL])
7894
}
7995

96+
private func synthesizeLandingPage(
97+
_ configuration: LandingPageInfo.SynthesizeConfiguration,
98+
combinedIndex: inout RenderIndex,
99+
targetURL: URL
100+
) throws {
101+
let landingPageName = configuration.name
102+
103+
let languages = combinedIndex.interfaceLanguages.keys.map { SourceLanguage(id: $0) }
104+
let language = languages.sorted().first ?? .swift
105+
106+
let reference = ResolvedTopicReference(bundleIdentifier: landingPageName.replacingWhitespaceAndPunctuation(with: "-"), path: "/documentation", sourceLanguage: language)
107+
108+
let rootRenderReferences = try readRootNodeRenderReferencesIn(dataDirectory: targetURL.appendingPathComponent("data", isDirectory: true))
109+
110+
guard !rootRenderReferences.isEmpty else {
111+
// No need to synthesize a landing page if the combined archive is empty.
112+
return
113+
}
114+
115+
let renderNode = makeSynthesizedLandingPage(
116+
name: landingPageName,
117+
reference: reference,
118+
roleHeading: configuration.kind,
119+
topicsStyle: configuration.style,
120+
rootRenderReferences: rootRenderReferences
121+
)
122+
123+
try fileManager.createFile(
124+
at: targetURL.appendingPathComponent("data/documentation.json"),
125+
contents: RenderJSONEncoder.makeEncoder().encode(renderNode)
126+
)
127+
// It's expected that this will fail if combined archive doesn't support static hosting.
128+
try? fileManager.copyItem(
129+
at: targetURL.appendingPathComponent("index.html"),
130+
to: targetURL.appendingPathComponent("/documentation/index.html")
131+
)
132+
133+
combinedIndex.insertRoot(named: landingPageName)
134+
}
135+
80136
/// Validate that the different archives don't have overlapping data.
81137
private func validateThatArchivesHaveDisjointData() throws {
82138
// Check that the archives don't have overlapping data

0 commit comments

Comments
 (0)