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 10 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 @@ -2650,7 +2650,6 @@ public class DocumentationContext {
}
}
}

/// A closure type getting the information about a reference in a context and returns any possible problems with it.
public typealias ReferenceCheck = (DocumentationContext, ResolvedTopicReference) -> [Problem]

Expand Down
13 changes: 13 additions & 0 deletions Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,20 @@ extension DocumentationBundle.Info {
self.unknownFeatureFlags = []
}

/// This feature flag corresponds to ``FeatureFlags/isExperimentalCodeBlockEnabled``.
public var experimentalCodeBlock: Bool?

public init(experimentalCodeBlock: Bool? = nil) {
self.experimentalCodeBlock = experimentalCodeBlock
self.unknownFeatureFlags = []
}

/// A list of decoded feature flag keys that didn't match a known feature flag.
public let unknownFeatureFlags: [String]

enum CodingKeys: String, CodingKey, CaseIterable {
case experimentalOverloadedSymbolPresentation = "ExperimentalOverloadedSymbolPresentation"
case experimentalCodeBlock = "ExperimentalCodeBlock"
}

struct AnyCodingKeys: CodingKey {
Expand All @@ -66,6 +75,9 @@ extension DocumentationBundle.Info {
switch codingKey {
case .experimentalOverloadedSymbolPresentation:
self.experimentalOverloadedSymbolPresentation = try values.decode(Bool.self, forKey: flagName)

case .experimentalCodeBlock:
self.experimentalCodeBlock = try values.decode(Bool.self, forKey: flagName)
}
} else {
unknownFeatureFlags.append(flagName.stringValue)
Expand All @@ -79,6 +91,7 @@ extension DocumentationBundle.Info {
var container = encoder.container(keyedBy: CodingKeys.self)

try container.encode(experimentalOverloadedSymbolPresentation, forKey: .experimentalOverloadedSymbolPresentation)
try container.encode(experimentalCodeBlock, forKey: .experimentalCodeBlock)
}
}
}
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 = true

/// 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 @@ -719,11 +721,13 @@ extension RenderBlockContent: Codable {
}
self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content)))
case .codeListing:
let copy = FeatureFlags.current.isExperimentalCodeBlockEnabled
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) ?? copy
))
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)))
case .orderedList:
Expand Down Expand Up @@ -826,6 +830,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
36 changes: 35 additions & 1 deletion Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,41 @@ 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))]

if FeatureFlags.current.isExperimentalCodeBlockEnabled {

struct ParsedOptions {
var lang: String?
var nocopy = false
}

func parseLanguageString(_ input: String?) -> ParsedOptions {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we have any plans to raise diagnostics here if we encounter an unknown option?

For example, the code below will parse swift, nocpoy as lang: nocpoy, nocopy: false and the only way for the developer to notice this is to see that the rendered code listing has a copy-to-clipboard button and doesn't have syntax highlighting.

If we want the possibility of raising diagnostics from this (or future) code block options, it probably needs be parsed earlier in the build, before "rendering".

Copy link
Contributor Author

@DebugSteven DebugSteven Aug 15, 2025

Choose a reason for hiding this comment

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

I think it would be a good idea to raise diagnostics for unknown options. I’m not familiar with DocC diagnostics and I don’t understand why it would need to be emitted earlier. Could you give me some more info?

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 == "nocopy" {
options.nocopy = true
} else if options.lang == nil {
options.lang = part
}
}
return options
}

let options = parseLanguageString(codeBlock.language)
Comment on lines +53 to +78
Copy link
Contributor

Choose a reason for hiding this comment

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

If I understand correctly, I think that we'd want to parse the code block's languages string into its components even when the feature flags is not enabled. Otherwise, a string like swift, nocopy wouldn't produce the any syntax highlighting unless the developer also passes the feature flag.

Copy link
Contributor

Choose a reason for hiding this comment

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

I feel like i have the opposite opinion here. If someone has written swift,nocopy, then they are explicitly aware of this feature flag and should be required to enable it to maintain the expected behavior. Otherwise they're writing a "weird language flag" that we would otherwise know nothing about.

Copy link
Contributor

Choose a reason for hiding this comment

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

A way we can soften the blow of working with this is to add this feature flag to BundleFeatureFlags to allow it to be set in Info.plist, like i did with the overloaded-symbol presentation in #891. That way, authors can set the feature flag in the same commit that they start to use the nocopy flags, and it should Just Work™.

Copy link
Contributor

Choose a reason for hiding this comment

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

That's fair. I can imagine different people being surprised about both behaviors.

@DebugSteven has already added this flag to BundleFeatureFlags, so that seems like a good way for people to adopt this while this feature is experimental.

Copy link
Contributor

Choose a reason for hiding this comment

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

As a mental note for myself and @QuietMisdreavus:
Looking at the BundleFeatureFlags code now, we will at some point need to figure out a strategy for deprecating and migrating an experimental flag in the Info.plist to its non-experimental replacement. We haven't needed to do so yet, but it's kind of weird that the container key includes the word "experimental" if it can contain non-experimental flags.


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

} else {
return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: false))]
}
}

mutating func visitHeading(_ heading: Heading) -> [any RenderContent] {
Expand Down
6 changes: 4 additions & 2 deletions Sources/SwiftDocC/Semantics/Snippets/Snippet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,13 @@ 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 = FeatureFlags.current.isExperimentalCodeBlockEnabled
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 copy = FeatureFlags.current.isExperimentalCodeBlockEnabled
let code = RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: snippetMixin.lines, metadata: nil, copyToClipboard: copy))
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
5 changes: 4 additions & 1 deletion Sources/SwiftDocC/Utility/FeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ public struct FeatureFlags: Codable {
/// The current feature flags that Swift-DocC uses to conditionally enable
/// (usually experimental) behavior in Swift-DocC.
public static var current = FeatureFlags()


/// Whether or not experimental annotation of code blocks is enabled.
public var isExperimentalCodeBlockEnabled = false

/// Whether or not experimental support for device frames on images and video is enabled.
public var isExperimentalDeviceFrameSupportEnabled = false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ extension ConvertAction {
public init(fromConvertCommand convert: Docc.Convert, withFallbackTemplate fallbackTemplateURL: URL? = nil) throws {
var standardError = LogHandle.standardError
let outOfProcessResolver: OutOfProcessReferenceResolver?

FeatureFlags.current.isExperimentalCodeBlockEnabled = convert.enableExperimentalCodeBlock
FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled = convert.enableExperimentalDeviceFrameSupport
FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = convert.enableExperimentalLinkHierarchySerialization
FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,13 @@ extension Docc {
struct FeatureFlagOptions: ParsableArguments {
@Flag(help: "Allows for custom templates, like `header.html`.")
var experimentalEnableCustomTemplates = false


@Flag(
name: .customLong("enable-experimental-code-block"),
help: "Support copy-to-clipboard for code blocks."
)
var enableExperimentalCodeBlock = false

@Flag(help: .hidden)
var enableExperimentalDeviceFrameSupport = false

Expand Down Expand Up @@ -557,6 +563,14 @@ extension Docc {

}

/// A user-provided value that is true if the user enables experimental support for code block annotation.
///
/// Defaults to false.
public var enableExperimentalCodeBlock: Bool {
get { featureFlags.enableExperimentalCodeBlock }
set { featureFlags.enableExperimentalCodeBlock = newValue}
}

/// A user-provided value that is true if the user enables experimental support for device frames.
///
/// Defaults to false.
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
93 changes: 93 additions & 0 deletions Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,97 @@ class RenderContentCompilerTests: XCTestCase {
XCTAssertEqual(documentThematicBreak, thematicBreak)
}
}

func testCopyToClipboard() async throws {
enableFeatureFlag(\.isExperimentalCodeBlockEnabled)
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: is there a corresponding test that checks that code listings don't have a copy-to-clipboard button when the feature flag isn't set? This could be as small as adding XCTAssertEqual(codeListing.copyToClipboard, false) to some existing test.

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 didn't see tests that already had Code Listings on them, so I opted to add a new test. If there's a place you'd prefer me to add this assertion, let me know and I can do that instead.


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
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)
}

func testNoCopyToClipboard() async throws {
enableFeatureFlag(\.isExperimentalCodeBlockEnabled)
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: Do we want to test the behavior when a code block contains swift, nocopy but the feature flag isn't enabled?

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 added another test for this as well. It shows that when the feature flag isn't enabled and a code block contains swift, nocopy that codeListing.syntax will equal the contents of the entire line, swift, nocopy in this case. Let me know if there is more I should do on that test.


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, nocopy
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, false)
}

func testCopyToClipboardNoFeatureFlag() 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
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, false)
}

func testNoCopyToClipboardNoFeatureFlag() 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, nocopy
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.syntax, "swift, nocopy")
XCTAssertEqual(codeListing.copyToClipboard, false)
}
}
4 changes: 2 additions & 2 deletions Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -514,8 +514,8 @@ 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: [
.paragraph(.init(inlineContent: [
Expand Down
3 changes: 3 additions & 0 deletions features.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"features": [
{
"name": "code-blocks"
},
{
"name": "diagnostics-file"
},
Expand Down