Skip to content

Commit

Permalink
Merge branch 'main' into treat-external-content-as-prerendered
Browse files Browse the repository at this point in the history
# Conflicts:
#	Sources/SwiftDocC/Infrastructure/DocumentationConverter.swift
#	Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift
  • Loading branch information
d-ronnqvist committed Feb 2, 2024
2 parents bfd1a96 + 910c402 commit b1bc2ae
Show file tree
Hide file tree
Showing 35 changed files with 2,001 additions and 298 deletions.
2 changes: 1 addition & 1 deletion Package.resolved
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
"location" : "https://github.com/apple/swift-docc-symbolkit",
"state" : {
"branch" : "main",
"revision" : "3889b9673fcf1f1e9651b9143289b6ede462c958"
"revision" : "2892437507cf3757814fa2715650a81303887b74"
}
},
{
Expand Down
183 changes: 183 additions & 0 deletions Sources/SwiftDocC/Catalog Processing/GeneratedCurationWriter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2024 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
import SymbolKit

/// A type that writes the auto-generated curation into documentation extension files.
public struct GeneratedCurationWriter {
let context: DocumentationContext
let catalogURL: URL?
let outputURL: URL
let linkResolver: PathHierarchyBasedLinkResolver

public init(
context: DocumentationContext,
catalogURL: URL?,
outputURL: URL
) {
self.context = context

self.catalogURL = catalogURL
self.outputURL = outputURL

self.linkResolver = context.linkResolver.localResolver
}

/// Generates the markdown representation of the auto-generated curation for a given symbol reference.
///
/// - Parameters:
/// - reference: The symbol reference to generate curation text for.
/// - Returns: The auto-generated curation text, or `nil` if this reference has no auto-generated curation.
func defaultCurationText(for reference: ResolvedTopicReference) -> String? {
guard let node = context.documentationCache[reference],
let symbol = node.semantic as? Symbol,
let automaticTopics = try? AutomaticCuration.topics(for: node, withTraits: [], context: context),
!automaticTopics.isEmpty
else {
return nil
}

let relativeLinks = linkResolver.disambiguatedRelativeLinksForDescendants(of: reference)

// Top-level curation has a few special behaviors regarding symbols with different representations in multiple languages.
let isForTopLevelCuration = symbol.kind.identifier == .module

var text = ""
for taskGroup in automaticTopics {
if isForTopLevelCuration, let firstReference = taskGroup.references.first, context.documentationCache[firstReference]?.symbol?.kind.identifier == .typeProperty {
// Skip type properties in top-level curation. It's not clear what's the right place for these symbols are since they exist in
// different places in different source languages (which documentation extensions don't yet have a way of representing).
continue
}

let links: [(link: String, comment: String?)] = taskGroup.references.compactMap { (curatedReference: ResolvedTopicReference) -> (String, String?)? in
guard let linkInfo = relativeLinks[curatedReference] else { return nil }
// If this link contains disambiguation, include a comment with the full symbol declaration to make it easier to know which symbol the link refers to.
var commentText: String?
if linkInfo.hasDisambiguation {
commentText = context.documentationCache[curatedReference]?.symbol?.declarationFragments?.map(\.spelling)
// Replace sequences of whitespace and newlines with a single space
.joined().split(whereSeparator: { $0.isWhitespace || $0.isNewline }).joined(separator: " ")
}

return ("\n- ``\(linkInfo.link)``", commentText.map { " <!-- \($0) -->" })
}

guard !links.isEmpty else { continue }

text.append("\n\n### \(taskGroup.title ?? "<!-- This auto-generated topic has no title -->")\n")

// Calculate the longest link to nicely align all the comments
let longestLink = links.map(\.link.count).max()! // `links` are non-empty so it's safe to force-unwrap `.max()` here
for (link, comment) in links {
if let comment = comment {
text.append(link.padding(toLength: longestLink, withPad: " ", startingAt: 0))
text.append(comment)
} else {
text.append(link)
}
}
}

guard !text.isEmpty else { return nil }

var prefix = "<!-- The content below this line is auto-generated and is redundant. You should either incorporate it into your content above this line or delete it. -->"

// Add "## Topics" to the curation text unless the symbol already had some manual curation.
let hasAnyManualCuration = symbol.topics?.taskGroups.isEmpty == false
if !hasAnyManualCuration {
prefix.append("\n\n## Topics")
}
return "\(prefix)\(text)\n"
}

enum Error: DescribedError {
case symbolLinkNotFound(TopicReferenceResolutionErrorInfo)

var errorDescription: String {
switch self {
case .symbolLinkNotFound(let errorInfo):
var errorMessage = "'--from-symbol <symbol-link>' not found: \(errorInfo.message)"
for solution in errorInfo.solutions {
errorMessage.append("\n\(solution.summary.replacingOccurrences(of: "\n", with: ""))")
}
return errorMessage
}
}
}

/// Generates documentation extension content with a markdown representation of DocC's auto-generated curation.
/// - Parameters:
/// - symbolLink: A link to the symbol whose sub hierarchy the curation writer will descend.
/// - depthLimit: The depth limit of how far the curation writer will descend from its starting point symbol.
/// - Returns: A collection of file URLs and their markdown content.
public func generateDefaultCurationContents(fromSymbol symbolLink: String? = nil, depthLimit: Int? = nil) throws -> [URL: String] {
// Used in documentation extension page titles to reference symbols that don't already have a documentation extension file.
let allAbsoluteLinks = linkResolver.pathHierarchy.disambiguatedAbsoluteLinks()

guard var curationCrawlRoot = linkResolver.modules().first else {
return [:]
}

if let symbolLink = symbolLink {
switch context.linkResolver.resolve(UnresolvedTopicReference(topicURL: .init(symbolPath: symbolLink)), in: curationCrawlRoot, fromSymbolLink: true, context: context) {
case .success(let foundSymbol):
curationCrawlRoot = foundSymbol
case .failure(_, let errorInfo):
throw Error.symbolLinkNotFound(errorInfo)
}
}

var contentsToWrite = [URL: String]()
for (usr, reference) in context.documentationCache.referencesBySymbolID {
// Filter out symbols that aren't in the specified sub hierarchy.
if symbolLink != nil || depthLimit != nil {
guard reference == curationCrawlRoot || context.pathsTo(reference).contains(where: { path in path.suffix(depthLimit ?? .max).contains(curationCrawlRoot)}) else {
continue
}
}

guard let absoluteLink = allAbsoluteLinks[usr], let curationText = defaultCurationText(for: reference) else { continue }
if let catalogURL = catalogURL, let existingURL = context.documentationExtensionURL(for: reference) {
let updatedFileURL: URL
if catalogURL == outputURL {
updatedFileURL = existingURL
} else {
var url = outputURL
let relativeComponents = existingURL.standardizedFileURL.pathComponents.dropFirst(catalogURL.standardizedFileURL.pathComponents.count)
for component in relativeComponents.dropLast() {
url.appendPathComponent(component, isDirectory: true)
}
url.appendPathComponent(relativeComponents.last!, isDirectory: false)
updatedFileURL = url
}
// Append to the end of the file. See if we can avoid reading the existing contents on disk.
var contents = try String(contentsOf: existingURL)
contents.append("\n")
contents.append(curationText)
contentsToWrite[updatedFileURL] = contents
} else {
let relativeReferencePath = reference.url.pathComponents.dropFirst(2).joined(separator: "/")
let fileName = urlReadablePath("/" + relativeReferencePath)
let newFileURL = NodeURLGenerator.fileSafeURL(outputURL.appendingPathComponent("\(fileName).md"))

let contents = """
# ``\(absoluteLink)``
\(curationText)
"""
contentsToWrite[newFileURL] = contents
}
}

return contentsToWrite
}
}
4 changes: 2 additions & 2 deletions Sources/SwiftDocC/Infrastructure/ContentCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ extension DocumentationContext {
/// > The cache is not thread-safe. It's safe to read from the cache concurrently but writing needs to happen with exclusive access. It is the callers responsibility to synchronize write access.
struct ContentCache<Value> {
/// The main storage of cached values.
private var valuesByReference = [ResolvedTopicReference: Value]()
private(set) var valuesByReference = [ResolvedTopicReference: Value]()
/// A supplementary lookup of references by their symbol ID.
///
/// If a reference is found, ``valuesByReference`` will also have a value for that reference because ``add(_:reference:symbolID:)`` is the only place that writes to this lookup and it always adds the reference-value pair to ``valuesByReference``.
private var referencesBySymbolID = [String: ResolvedTopicReference]()
private(set) var referencesBySymbolID = [String: ResolvedTopicReference]()

/// Accesses the value for a given reference.
/// - Parameter reference: The reference to find in the cache.
Expand Down
16 changes: 15 additions & 1 deletion Sources/SwiftDocC/Infrastructure/DocumentationConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
/// The source repository where the documentation's sources are hosted.
var sourceRepository: SourceRepository?

var experimentalModifyCatalogWithGeneratedCuration: Bool

/// The identifiers and access level requirements for symbols that have an expanded version of their documentation page if the requirements are met
var symbolIdentifiersWithExpandedDocumentation: [String: ConvertRequest.ExpandedDocumentationRequirements]? = nil

Expand Down Expand Up @@ -139,7 +141,8 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
sourceRepository: SourceRepository? = nil,
isCancelled: Synchronized<Bool>? = nil,
diagnosticEngine: DiagnosticEngine = .init(),
symbolIdentifiersWithExpandedDocumentation: [String: ConvertRequest.ExpandedDocumentationRequirements]? = nil
symbolIdentifiersWithExpandedDocumentation: [String: ConvertRequest.ExpandedDocumentationRequirements]? = nil,
experimentalModifyCatalogWithGeneratedCuration: Bool = false
) {
self.rootURL = documentationBundleURL
self.emitDigest = emitDigest
Expand All @@ -156,6 +159,7 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
self.isCancelled = isCancelled
self.diagnosticEngine = diagnosticEngine
self.symbolIdentifiersWithExpandedDocumentation = symbolIdentifiersWithExpandedDocumentation
self.experimentalModifyCatalogWithGeneratedCuration = experimentalModifyCatalogWithGeneratedCuration

// Inject current platform versions if provided
if let currentPlatforms = currentPlatforms {
Expand Down Expand Up @@ -246,6 +250,16 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
// For now, we only support one bundle.
let bundle = bundles.first!

if experimentalModifyCatalogWithGeneratedCuration, let catalogURL = rootURL {
let writer = GeneratedCurationWriter(context: context, catalogURL: catalogURL, outputURL: catalogURL)
let curation = try writer.generateDefaultCurationContents()
for (url, updatedContent) in curation {
guard let data = updatedContent.data(using: .utf8) else { continue }
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true, attributes: nil)
try? data.write(to: url, options: .atomic)
}
}

guard !context.problems.containsErrors else {
if emitDigest {
try outputConsumer.consume(problems: context.problems)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,90 @@ extension PathHierarchy {
/// - Parameters:
/// - includeDisambiguationForUnambiguousChildren: Whether or not descendants unique to a single collision should maintain the containers disambiguation.
/// - includeLanguage: Whether or not kind disambiguation information should include the source language.
/// - Returns: A map of unique identifier strings to disambiguated file paths
/// - Returns: A map of unique identifier strings to disambiguated file paths.
func caseInsensitiveDisambiguatedPaths(
includeDisambiguationForUnambiguousChildren: Bool = false,
includeLanguage: Bool = false
) -> [String: String] {
return disambiguatedPaths(
caseSensitive: false,
transformToFileNames: true,
includeDisambiguationForUnambiguousChildren: includeDisambiguationForUnambiguousChildren,
includeLanguage: includeLanguage
)
}

/// Determines the disambiguated relative links of all the direct descendants of the given node.
///
/// - Parameters:
/// - nodeID: The identifier of the node to determine direct descendant links for.
/// - Returns: A map if node identifiers to pairs of links and flags indicating if the link is disambiguated or not.
func disambiguatedChildLinks(of nodeID: ResolvedIdentifier) -> [ResolvedIdentifier: (link: String, hasDisambiguation: Bool)] {
let node = lookup[nodeID]!

var gathered = [(symbolID: String, (link: String, hasDisambiguation: Bool, id: ResolvedIdentifier, isSwift: Bool))]()
for (_, tree) in node.children {
let disambiguatedChildren = tree.disambiguatedValuesWithCollapsedUniqueSymbols(includeLanguage: false)

for (node, disambiguation) in disambiguatedChildren {
guard let id = node.identifier, let symbolID = node.symbol?.identifier.precise else { continue }
let suffix = disambiguation.makeSuffix()
gathered.append((
symbolID: symbolID, (
link: node.name + suffix,
hasDisambiguation: !suffix.isEmpty,
id: id,
isSwift: node.symbol?.identifier.interfaceLanguage == "swift"
)
))
}
}

// If a symbol node exist in multiple languages, prioritize the Swift variant.
let uniqueSymbolValues = Dictionary(gathered, uniquingKeysWith: { lhs, rhs in lhs.isSwift ? lhs : rhs })
.values.map({ ($0.id, ($0.link, $0.hasDisambiguation)) })
return .init(uniqueKeysWithValues: uniqueSymbolValues)
}

/// Determines the least disambiguated links for all symbols in the path hierarchy.
///
/// - Returns: A map of unique identifier strings to disambiguated links.
func disambiguatedAbsoluteLinks() -> [String: String] {
return disambiguatedPaths(
caseSensitive: true,
transformToFileNames: false,
includeDisambiguationForUnambiguousChildren: false,
includeLanguage: false
)
}

private func disambiguatedPaths(
caseSensitive: Bool,
transformToFileNames: Bool,
includeDisambiguationForUnambiguousChildren: Bool,
includeLanguage: Bool
) -> [String: String] {
let nameTransform: (String) -> String
if transformToFileNames {
nameTransform = symbolFileName(_:)
} else {
nameTransform = { $0 }
}

func descend(_ node: Node, accumulatedPath: String) -> [(String, (String, Bool))] {
var results: [(String, (String, Bool))] = []
let caseInsensitiveChildren = [String: DisambiguationContainer](node.children.map { (symbolFileName($0.key.lowercased()), $0.value) }, uniquingKeysWith: { $0.merge(with: $1) })
let children = [String: DisambiguationContainer](node.children.map {
var name = $0.key
if !caseSensitive {
name = name.lowercased()
}
return (nameTransform(name), $0.value)
}, uniquingKeysWith: { $0.merge(with: $1) })

for (_, tree) in caseInsensitiveChildren {
for (_, tree) in children {
let disambiguatedChildren = tree.disambiguatedValuesWithCollapsedUniqueSymbols(includeLanguage: includeLanguage)
let uniqueNodesWithChildren = Set(disambiguatedChildren.filter { $0.disambiguation.value() != nil && !$0.value.children.isEmpty }.map { $0.value.symbol?.identifier.precise })

for (node, disambiguation) in disambiguatedChildren {
var path: String
if node.identifier == nil && disambiguatedChildren.count == 1 {
Expand All @@ -48,9 +120,9 @@ extension PathHierarchy {
if hash != "_" {
knownDisambiguation += "-\(hash)"
}
path = accumulatedPath + "/" + symbolFileName(node.name) + knownDisambiguation
path = accumulatedPath + "/" + nameTransform(node.name) + knownDisambiguation
} else {
path = accumulatedPath + "/" + symbolFileName(node.name)
path = accumulatedPath + "/" + nameTransform(node.name)
}
if let symbol = node.symbol {
results.append(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,26 @@ final class PathHierarchyBasedLinkResolver {
}
return result
}

// MARK: Links

/// Determines the disambiguated relative links of all the direct descendants of the given page.
///
/// - Parameters:
/// - reference: The identifier of the page whose descendants to generate relative links for.
/// - Returns: A map topic references to pairs of links and flags indicating if the link is disambiguated or not.
func disambiguatedRelativeLinksForDescendants(of reference: ResolvedTopicReference) -> [ResolvedTopicReference: (link: String, hasDisambiguation: Bool)] {
guard let nodeID = resolvedReferenceMap[reference] else { return [:] }

let links = pathHierarchy.disambiguatedChildLinks(of: nodeID)
var result = [ResolvedTopicReference: (link: String, hasDisambiguation: Bool)]()
result.reserveCapacity(links.count)
for (id, link) in links {
guard let reference = resolvedReferenceMap[id] else { continue }
result[reference] = link
}
return result
}
}

/// Creates a more writable version of an articles file name for use in documentation links.
Expand Down
Loading

0 comments on commit b1bc2ae

Please sign in to comment.