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
11 changes: 9 additions & 2 deletions Sources/SwiftDocC/Converter/DocumentationContextConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public class DocumentationContextConverter {
/// Whether the documentation converter should include access level information for symbols.
let shouldEmitSymbolAccessLevels: Bool

/// The remote source control repository where the documented module's source is hosted.
let sourceRepository: SourceRepository?

/// Creates a new node converter for the given bundle and context.
///
/// The converter uses bundle and context to resolve references to other documentation and describe the documentation hierarchy.
Expand All @@ -51,18 +54,21 @@ public class DocumentationContextConverter {
/// Before passing `true` please confirm that your use case doesn't include public
/// distribution of any created render nodes as there are filesystem privacy and security
/// concerns with distributing this data.
/// - sourceRepository: The source repository where the documentation's sources are hosted.
public init(
bundle: DocumentationBundle,
context: DocumentationContext,
renderContext: RenderContext,
emitSymbolSourceFileURIs: Bool = false,
emitSymbolAccessLevels: Bool = false
emitSymbolAccessLevels: Bool = false,
sourceRepository: SourceRepository? = nil
) {
self.bundle = bundle
self.context = context
self.renderContext = renderContext
self.shouldEmitSymbolSourceFileURIs = emitSymbolSourceFileURIs
self.shouldEmitSymbolAccessLevels = emitSymbolAccessLevels
self.sourceRepository = sourceRepository
}

/// Converts a documentation node to a render node.
Expand All @@ -84,7 +90,8 @@ public class DocumentationContextConverter {
source: source,
renderContext: renderContext,
emitSymbolSourceFileURIs: shouldEmitSymbolSourceFileURIs,
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels,
sourceRepository: sourceRepository
)
return translator.visit(node.semantic) as? RenderNode
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
/// Whether the documentation converter should include access level information for symbols.
var shouldEmitSymbolAccessLevels: Bool

/// The source repository where the documentation's sources are hosted.
var sourceRepository: SourceRepository?

/// `true` if the conversion is cancelled.
private var isCancelled: Synchronized<Bool>? = nil

Expand Down Expand Up @@ -128,6 +131,7 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
bundleDiscoveryOptions: BundleDiscoveryOptions,
emitSymbolSourceFileURIs: Bool = false,
emitSymbolAccessLevels: Bool = false,
sourceRepository: SourceRepository? = nil,
isCancelled: Synchronized<Bool>? = nil,
diagnosticEngine: DiagnosticEngine = .init()
) {
Expand All @@ -142,6 +146,7 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
self.bundleDiscoveryOptions = bundleDiscoveryOptions
self.shouldEmitSymbolSourceFileURIs = emitSymbolSourceFileURIs
self.shouldEmitSymbolAccessLevels = emitSymbolAccessLevels
self.sourceRepository = sourceRepository
self.isCancelled = isCancelled
self.diagnosticEngine = diagnosticEngine

Expand Down Expand Up @@ -247,7 +252,8 @@ public struct DocumentationConverter: DocumentationConverterProtocol {
context: context,
renderContext: renderContext,
emitSymbolSourceFileURIs: shouldEmitSymbolSourceFileURIs,
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels
emitSymbolAccessLevels: shouldEmitSymbolAccessLevels,
sourceRepository: sourceRepository
)

var indexingRecords = [IndexingRecord]()
Expand Down
28 changes: 28 additions & 0 deletions Sources/SwiftDocC/Model/Rendering/RenderNode/RenderMetadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,15 @@ public struct RenderMetadata: VariantContainer {
/// The variants for the source file URI of a page.
public var sourceFileURIVariants: VariantCollection<String?> = .init(defaultValue: nil)

/// The remote location where the source declaration of the topic can be viewed.
public var remoteSource: RemoteSource? {
get { getVariantDefaultValue(keyPath: \.remoteSourceVariants) }
set { setVariantDefaultValue(newValue, keyPath: \.remoteSourceVariants) }
}

/// The variants for the topic's remote source.
public var remoteSourceVariants: VariantCollection<RemoteSource?> = .init(defaultValue: nil)

/// Any tags assigned to the node.
public var tags: [RenderNode.Tag]?
}
Expand All @@ -163,6 +172,21 @@ extension RenderMetadata: Codable {
/// but have no authoring support at the moment.
public let relatedModules: [String]?
}

/// Describes the location of the topic's source code, hosted remotely by a source service.
public struct RemoteSource: Codable, Equatable {
/// The name of the file where the topic is declared.
public var fileName: String

/// The location of the topic's source code, hosted by a source service.
public var url: URL

/// Creates a topic's source given its source code's file name and URL.
public init(fileName: String, url: URL) {
self.fileName = fileName
self.url = url
}
}

public struct CodingKeys: CodingKey, Hashable {
public var stringValue: String
Expand Down Expand Up @@ -196,6 +220,7 @@ extension RenderMetadata: Codable {
public static let fragments = CodingKeys(stringValue: "fragments")
public static let navigatorTitle = CodingKeys(stringValue: "navigatorTitle")
public static let sourceFileURI = CodingKeys(stringValue: "sourceFileURI")
public static let remoteSource = CodingKeys(stringValue: "remoteSource")
public static let tags = CodingKeys(stringValue: "tags")
}

Expand All @@ -221,6 +246,7 @@ extension RenderMetadata: Codable {
fragmentsVariants = try container.decodeVariantCollectionIfPresent(ofValueType: [DeclarationRenderSection.Token]?.self, forKey: .fragments)
navigatorTitleVariants = try container.decodeVariantCollectionIfPresent(ofValueType: [DeclarationRenderSection.Token]?.self, forKey: .navigatorTitle)
sourceFileURIVariants = try container.decodeVariantCollectionIfPresent(ofValueType: String?.self, forKey: .sourceFileURI)
remoteSourceVariants = try container.decodeVariantCollectionIfPresent(ofValueType: RemoteSource?.self, forKey: .remoteSource)
tags = try container.decodeIfPresent([RenderNode.Tag].self, forKey: .tags)

let extraKeys = Set(container.allKeys).subtracting(
Expand All @@ -242,6 +268,7 @@ extension RenderMetadata: Codable {
.fragments,
.navigatorTitle,
.sourceFileURI,
.remoteSource,
.tags
]
)
Expand Down Expand Up @@ -272,6 +299,7 @@ extension RenderMetadata: Codable {
try container.encodeVariantCollection(fragmentsVariants, forKey: .fragments, encoder: encoder)
try container.encodeVariantCollection(navigatorTitleVariants, forKey: .navigatorTitle, encoder: encoder)
try container.encodeVariantCollection(sourceFileURIVariants, forKey: .sourceFileURI, encoder: encoder)
try container.encodeVariantCollection(remoteSourceVariants, forKey: .remoteSource, encoder: encoder)
if let tags = self.tags, !tags.isEmpty {
try container.encodeIfPresent(tags, forKey: .tags)
}
Expand Down
27 changes: 26 additions & 1 deletion Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public struct RenderNodeTranslator: SemanticVisitor {
/// Whether the documentation converter should include access level information for symbols.
var shouldEmitSymbolAccessLevels: Bool

/// The source repository where the documentation's sources are hosted.
var sourceRepository: SourceRepository?

public mutating func visitCode(_ code: Code) -> RenderTree? {
let fileType = NSString(string: code.fileName).pathExtension
let fileReference = code.fileReference
Expand Down Expand Up @@ -1157,6 +1160,26 @@ public struct RenderNodeTranslator: SemanticVisitor {
} ?? .init(defaultValue: nil)
}

if let sourceRepository = sourceRepository {
node.metadata.remoteSourceVariants = VariantCollection<RenderMetadata.RemoteSource?>(
from: symbol.locationVariants
) { _, location in
guard let locationURL = location.url(),
let url = sourceRepository.format(
sourceFileURL: locationURL,
lineNumber: location.position.line + 1
)
else {
return nil
}

return RenderMetadata.RemoteSource(
fileName: locationURL.lastPathComponent,
url: url
)
} ?? .init(defaultValue: nil)
}

if shouldEmitSymbolAccessLevels {
node.metadata.symbolAccessLevelVariants = VariantCollection<String?>(from: symbol.accessLevelVariants)
}
Expand Down Expand Up @@ -1553,7 +1576,8 @@ public struct RenderNodeTranslator: SemanticVisitor {
source: URL?,
renderContext: RenderContext? = nil,
emitSymbolSourceFileURIs: Bool = false,
emitSymbolAccessLevels: Bool = false
emitSymbolAccessLevels: Bool = false,
sourceRepository: SourceRepository? = nil
) {
self.context = context
self.bundle = bundle
Expand All @@ -1563,6 +1587,7 @@ public struct RenderNodeTranslator: SemanticVisitor {
self.contentRenderer = DocumentationContentRenderer(documentationContext: context, bundle: bundle)
self.shouldEmitSymbolSourceFileURIs = emitSymbolSourceFileURIs
self.shouldEmitSymbolAccessLevels = emitSymbolAccessLevels
self.sourceRepository = sourceRepository
}
}

Expand Down
92 changes: 92 additions & 0 deletions Sources/SwiftDocC/SourceRepository/SourceRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2022 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

/// A remote repository that hosts source code.
public struct SourceRepository {
/// The path at which the repository is cloned locally.
public var checkoutPath: String

/// The base URL where the service hosts the repository's contents.
public var sourceServiceBaseURL: URL

/// A function that formats a line number to be included in a URL.
public var formatLineNumber: (Int) -> String

/// Creates a source code repository.
/// - Parameters:
/// - checkoutPath: The path at which the repository is checked out locally and from which its symbol graphs were generated.
/// - sourceServiceBaseURL: The base URL where the service hosts the repository's contents.
/// - formatLineNumber: A function that formats a line number to be included in a URL.
public init(
checkoutPath: String,
sourceServiceBaseURL: URL,
formatLineNumber: @escaping (Int) -> String
) {
self.checkoutPath = checkoutPath
self.sourceServiceBaseURL = sourceServiceBaseURL
self.formatLineNumber = formatLineNumber
}

/// Formats a local source file URL to a URL hosted by the remote source code service.
/// - Parameters:
/// - sourceFileURL: The location of the source file on disk.
/// - lineNumber: A line number in the source file, 1-indexed.
/// - Returns: The URL of the file hosted by the remote source code service if it could be constructed, otherwise, `nil`.
public func format(sourceFileURL: URL, lineNumber: Int? = nil) -> URL? {
guard sourceFileURL.path.hasPrefix(checkoutPath) else {
return nil
}

let path = sourceFileURL.path.dropFirst(checkoutPath.count).removingLeadingSlash
return sourceServiceBaseURL
.appendingPathComponent(path)
.withFragment(lineNumber.map(formatLineNumber))
}
}

public extension SourceRepository {
/// Creates a source repository hosted by the GitHub service.
/// - Parameters:
/// - checkoutPath: The path of the local checkout.
/// - sourceServiceBaseURL: The base URL where the service hosts the repository's contents.
static func github(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
SourceRepository(
checkoutPath: checkoutPath,
sourceServiceBaseURL: sourceServiceBaseURL,
formatLineNumber: { line in "L\(line)" }
)
}

/// Creates a source repository hosted by the GitLab service.
/// - Parameters:
/// - checkoutPath: The path of the local checkout.
/// - sourceServiceBaseURL: The base URL where the service hosts the repository's contents.
static func gitlab(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
SourceRepository(
checkoutPath: checkoutPath,
sourceServiceBaseURL: sourceServiceBaseURL,
formatLineNumber: { line in "L\(line)" }
)
}

/// Creates a source repository hosted by the BitBucket service.
/// - Parameters:
/// - checkoutPath: The path of the local checkout.
/// - sourceServiceBaseURL: The base URL where the service hosts the repository's contents.
static func bitbucket(checkoutPath: String, sourceServiceBaseURL: URL) -> SourceRepository {
SourceRepository(
checkoutPath: checkoutPath,
sourceServiceBaseURL: sourceServiceBaseURL,
formatLineNumber: { line in "lines-\(line)" }
Copy link
Contributor

Choose a reason for hiding this comment

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

It looks like lines- or line- will work here but line- seems a little nicer to me since we'll never be linking to a block.

Suggested change
formatLineNumber: { line in "lines-\(line)" }
formatLineNumber: { line in "line-\(line)" }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I couldn't find an official format reference from BitBucket, but lines- is what their website uses when you click on a file number, e.g., https://bitbucket.org/kannemadugupriyanka/sb1-test-fork-test-test/src/8f1c1b45b4622388b241cf281b595b4e02cef258/Test%20Test/.project#lines-4, so given that it seems safer to use lines-.

Copy link
Contributor

Choose a reason for hiding this comment

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

Sounds good. I did come across this from BitBucket as a spec for what it's worth: https://support.atlassian.com/bitbucket-cloud/docs/hyperlink-to-source-code-in-bitbucket/.

But it lists something entirely different from what the UI does: #<filename>-<linenumber>.

https://bitbucket.org/kannemadugupriyanka/sb1-test-fork-test-test/src/8f1c1b45b4622388b241cf281b595b4e02cef258/Test%20Test/.project#.project-4

And then the <filename> part is technically optional actually so:

https://bitbucket.org/kannemadugupriyanka/sb1-test-fork-test-test/src/8f1c1b45b4622388b241cf281b595b4e02cef258/Test%20Test/.project#-4

works as well.

Any way 😃 Just matching the behavior of the UI seems fine enough.

)
}
}
15 changes: 15 additions & 0 deletions Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -2048,6 +2048,21 @@
"sourceFileURI": {
"type": "string"
},
"remoteSource": {
"type": "object",
"required": [
"fileName",
"url"
],
"properties": {
"fileName": {
"type": "string"
},
"url": {
"type": "string"
}
}
},
"tags": {
"type": "array",
"items": {
Expand Down
11 changes: 8 additions & 3 deletions Sources/SwiftDocC/Utility/FoundationExtensions/String+Path.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,21 @@

import Foundation

extension String {
extension StringProtocol {
/// A copy of the string prefixed with a slash ("/") if the string doesn't already start with a leading slash.
var prependingLeadingSlash: String {
guard !hasPrefix("/") else { return self }
guard !hasPrefix("/") else { return String(self) }
return "/".appending(self)
}

/// A copy of the string without a leading slash ("/") or the original string if it doesn't start with a leading slash.
var removingLeadingSlash: String {
guard hasPrefix("/") else { return self }
guard hasPrefix("/") else { return String(self) }
return String(dropFirst())
}

var removingTrailingSlash: String {
guard hasSuffix("/") else { return String(self) }
return String(dropLast())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ public struct ConvertAction: Action, RecreatingContext {
let transformForStaticHosting: Bool
let hostingBasePath: String?

let sourceRepository: SourceRepository?

private(set) var context: DocumentationContext {
didSet {
Expand Down Expand Up @@ -100,7 +101,8 @@ public struct ConvertAction: Action, RecreatingContext {
inheritDocs: Bool = false,
experimentalEnableCustomTemplates: Bool = false,
transformForStaticHosting: Bool = false,
hostingBasePath: String? = nil
hostingBasePath: String? = nil,
sourceRepository: SourceRepository? = nil
) throws
{
self.rootURL = documentationBundleURL
Expand All @@ -117,6 +119,7 @@ public struct ConvertAction: Action, RecreatingContext {
self.documentationCoverageOptions = documentationCoverageOptions
self.transformForStaticHosting = transformForStaticHosting
self.hostingBasePath = hostingBasePath
self.sourceRepository = sourceRepository

let filterLevel: DiagnosticSeverity
if analyze {
Expand Down Expand Up @@ -180,6 +183,7 @@ public struct ConvertAction: Action, RecreatingContext {
context: self.context,
dataProvider: dataProvider,
bundleDiscoveryOptions: bundleDiscoveryOptions,
sourceRepository: sourceRepository,
isCancelled: isCancelled,
diagnosticEngine: self.diagnosticEngine
)
Expand Down Expand Up @@ -208,6 +212,7 @@ public struct ConvertAction: Action, RecreatingContext {
experimentalEnableCustomTemplates: Bool = false,
transformForStaticHosting: Bool,
hostingBasePath: String?,
sourceRepository: SourceRepository? = nil,
temporaryDirectory: URL
) throws {
// Note: This public initializer exists separately from the above internal one
Expand Down Expand Up @@ -239,7 +244,8 @@ public struct ConvertAction: Action, RecreatingContext {
inheritDocs: inheritDocs,
experimentalEnableCustomTemplates: experimentalEnableCustomTemplates,
transformForStaticHosting: transformForStaticHosting,
hostingBasePath: hostingBasePath
hostingBasePath: hostingBasePath,
sourceRepository: sourceRepository
)
}

Expand Down
Loading