Skip to content

Add copy-to-clipboard support to code blocks #1273

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,14 @@ public enum RenderBlockContent: Equatable {
public var code: [String]
/// Additional metadata for this code block.
public var metadata: RenderContentMetadata?
public var copyToClipboard: Bool = false

/// Make a new `CodeListing` with the given data.
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?) {
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool) {
self.syntax = syntax
self.code = code
self.metadata = metadata
self.copyToClipboard = copyToClipboard
}
}

Expand Down Expand Up @@ -697,7 +699,7 @@ extension RenderBlockContent.Table: Codable {
extension RenderBlockContent: Codable {
private enum CodingKeys: CodingKey {
case type
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard
case request, response
case header, rows
case numberOfColumns, columns
Expand All @@ -722,7 +724,8 @@ extension RenderBlockContent: Codable {
self = try .codeListing(.init(
syntax: container.decodeIfPresent(String.self, forKey: .syntax),
code: container.decode([String].self, forKey: .code),
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata),
copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? false
))
case .heading:
self = try .heading(.init(level: container.decode(Int.self, forKey: .level), text: container.decode(String.self, forKey: .text), anchor: container.decodeIfPresent(String.self, forKey: .anchor)))
Expand Down Expand Up @@ -826,6 +829,7 @@ extension RenderBlockContent: Codable {
try container.encode(l.syntax, forKey: .syntax)
try container.encode(l.code, forKey: .code)
try container.encodeIfPresent(l.metadata, forKey: .metadata)
try container.encode(l.copyToClipboard, forKey: .copyToClipboard)
case .heading(let h):
try container.encode(h.level, forKey: .level)
try container.encode(h.text, forKey: .text)
Expand Down
29 changes: 28 additions & 1 deletion Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,34 @@ struct RenderContentCompiler: MarkupVisitor {

mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> [any RenderContent] {
// Default to the bundle's code listing syntax if one is not explicitly declared in the code block.
return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil))]
struct ParsedOptions {
var lang: String?
var copy = false
}

func parseLanguageString(_ input: String?) -> ParsedOptions {
guard let input else { return ParsedOptions() }

let parts = input
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }

var options = ParsedOptions()

for part in parts {
let lower = part.lowercased()
if lower == "copy" {
options.copy = true
} else if options.lang == nil {
options.lang = part
}
}
return options
}

let options = parseLanguageString(codeBlock.language)

return [RenderBlockContent.codeListing(.init(syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: options.copy))]
}

mutating func visitHeading(_ heading: Heading) -> [any RenderContent] {
Expand Down
5 changes: 3 additions & 2 deletions Sources/SwiftDocC/Semantics/Snippets/Snippet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,12 @@ extension Snippet: RenderableDirectiveConvertible {
let lines = snippetMixin.lines[lineRange]
let minimumIndentation = lines.map { $0.prefix { $0.isWhitespace }.count }.min() ?? 0
let trimmedLines = lines.map { String($0.dropFirst(minimumIndentation)) }
return [RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: trimmedLines, metadata: nil))]
let copy = true
return [RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: trimmedLines, metadata: nil, copyToClipboard: copy))]
} else {
// Render the whole snippet with its explanation content.
let docCommentContent = snippetEntity.markup.children.flatMap { contentCompiler.visit($0) }
let code = RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: snippetMixin.lines, metadata: nil))
let code = RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: snippetMixin.lines, metadata: nil, copyToClipboard: false))
return docCommentContent + [code]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,9 @@
},
"metadata": {
"$ref": "#/components/schemas/RenderContentMetadata"
},
"copyToClipboard": {
"type": "boolean"
}
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,24 @@ add a new line and terminate the code listing by adding another three backticks:
instead of tabs so that DocC preserves the indentation when compiling your
documentation.

#### Formatting Code Listings

You can add a copy-to-clipboard button to a code listing by including the copy
option after the name of the programming language for the code listing:

```swift, copy
struct Sightseeing: Activity {
func perform(with sloth: inout Sloth) -> Speed {
sloth.energyLevel -= 10
return .slow
}
}
```

This renders a copy button in the top-right cotner of the code listing in
generated documentation. When clicked, it copies the contents of the code
block to the clipboard.

DocC uses the programming language you specify to apply the correct syntax
color formatting. For the example above, DocC generates the following:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class RenderContentMetadataTests: XCTestCase {
RenderInlineContent.text("Content"),
])

let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata))
let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata, copyToClipboard: false))
let data = try JSONEncoder().encode(code)
let roundtrip = try JSONDecoder().decode(RenderBlockContent.self, from: data)

Expand Down
10 changes: 5 additions & 5 deletions Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ class RenderNodeSerializationTests: XCTestCase {
.strong(inlineContent: [.text("Project > Run")]),
.text(" menu item, or the following code:"),
])),
.codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil)),
.codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, copyToClipboard: false)),
]))
]

Expand All @@ -71,16 +71,16 @@ class RenderNodeSerializationTests: XCTestCase {
let assessment1 = TutorialAssessmentsRenderSection.Assessment(title: [.paragraph(.init(inlineContent: [.text("Lorem ipsum dolor sit amet?")]))],
content: nil,
choices: [
.init(content: [.codeListing(.init(syntax: "swift", code: ["override func viewDidLoad() {", "super.viewDidLoad()", "}"], metadata: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "That's right!"),
.init(content: [.codeListing(.init(syntax: "swift", code: ["sceneView.delegate = self"], metadata: nil))], isCorrect: false, justification: [.paragraph(.init(inlineContent: [.text("It's incorrect because...")]))], reaction: "Not quite."),
.init(content: [.codeListing(.init(syntax: "swift", code: ["override func viewDidLoad() {", "super.viewDidLoad()", "}"], metadata: nil, copyToClipboard: false))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "That's right!"),
.init(content: [.codeListing(.init(syntax: "swift", code: ["sceneView.delegate = self"], metadata: nil, copyToClipboard: false))], isCorrect: false, justification: [.paragraph(.init(inlineContent: [.text("It's incorrect because...")]))], reaction: "Not quite."),
.init(content: [.paragraph(.init(inlineContent: [.text("None of the above.")]))], isCorrect: false, justification: [.paragraph(.init(inlineContent: [.text("It's incorrect because...")]))], reaction: nil),
])

let assessment2 = TutorialAssessmentsRenderSection.Assessment(title: [.paragraph(.init(inlineContent: [.text("Duis aute irure dolor in reprehenderit?")]))],
content: [.paragraph(.init(inlineContent: [.text("What is the airspeed velocity of an unladen swallow?")]))],
choices: [
.init(content: [.codeListing(.init(syntax: "swift", code: ["super.viewWillAppear()"], metadata: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Correct."),
.init(content: [.codeListing(.init(syntax: "swift", code: ["sceneView.delegate = self"], metadata: nil))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Yep."),
.init(content: [.codeListing(.init(syntax: "swift", code: ["super.viewWillAppear()"], metadata: nil, copyToClipboard: false))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Correct."),
.init(content: [.codeListing(.init(syntax: "swift", code: ["sceneView.delegate = self"], metadata: nil, copyToClipboard: false))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Yep."),
.init(content: [.paragraph(.init(inlineContent: [.text("None of the above.")]))], isCorrect: false, justification: [.paragraph(.init(inlineContent: [.text("It's incorrect because...")]))], reaction: "Close!"),
])

Expand Down
22 changes: 22 additions & 0 deletions Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,26 @@ class RenderContentCompilerTests: XCTestCase {
XCTAssertEqual(documentThematicBreak, thematicBreak)
}
}

func testCopyToClipboard() async throws {
let (bundle, context) = try await testBundleAndContext()
var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift))

let source = #"""
```swift, copy
let x = 1
```
"""#
let document = Document(parsing: source)

let result = document.children.flatMap { compiler.visit($0) }

let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent)
guard case let .codeListing(codeListing) = renderCodeBlock else {
XCTFail("Expected RenderBlockContent.codeListing")
return
}

XCTAssertEqual(codeListing.copyToClipboard, true)
}
}
2 changes: 1 addition & 1 deletion Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ class ListItemExtractorTests: XCTestCase {
// ```
// Inner code block
// ```
.codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil)),
.codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: false)),

// > Warning: Inner aside, with ``ThirdNotFoundSymbol`` link
.aside(.init(style: .init(asideKind: .warning), content: [
Expand Down