Skip to content

Commit 7f0d3f6

Browse files
DebugStevenJesse Haigh
andauthored
Add copy-to-clipboard support for code blocks (with 'nocopy' annotation to disable) (#1273)
* Adds a `copyToClipboard` property on `CodeListing` * Introduces the `enable-experimental-code-block-annotations` feature flag * Supports disabling copy with the `nocopy` annotation, parsed from the code block’s language line * Updates the OpenAPI spec to include `copyToClipboard` Co-authored-by: Jesse Haigh <[email protected]>
1 parent bb2cdc7 commit 7f0d3f6

File tree

15 files changed

+367
-17
lines changed

15 files changed

+367
-17
lines changed
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 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+
internal import Foundation
12+
internal import Markdown
13+
14+
/**
15+
Code blocks can have a `nocopy` option after the \`\`\`, in the language line.
16+
`nocopy` can be immediately after the \`\`\` or after a specified language and a comma (`,`).
17+
*/
18+
internal struct InvalidCodeBlockOption: Checker {
19+
var problems = [Problem]()
20+
21+
/// Parsing options for code blocks
22+
private let knownOptions = RenderBlockContent.CodeListing.knownOptions
23+
24+
private var sourceFile: URL?
25+
26+
/// Creates a new checker that detects documents with multiple titles.
27+
///
28+
/// - Parameter sourceFile: The URL to the documentation file that the checker checks.
29+
init(sourceFile: URL?) {
30+
self.sourceFile = sourceFile
31+
}
32+
33+
mutating func visitCodeBlock(_ codeBlock: CodeBlock) {
34+
let info = codeBlock.language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
35+
guard !info.isEmpty else { return }
36+
37+
let tokens = info
38+
.split(separator: ",")
39+
.map { $0.trimmingCharacters(in: .whitespaces) }
40+
.filter { !$0.isEmpty }
41+
42+
guard !tokens.isEmpty else { return }
43+
44+
for token in tokens {
45+
// if the token is an exact match, we don't need to do anything
46+
guard !knownOptions.contains(token) else { continue }
47+
48+
let matches = NearMiss.bestMatches(for: knownOptions, against: token)
49+
50+
if !matches.isEmpty {
51+
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(token.singleQuoted) in code block.")
52+
let possibleSolutions = matches.map { candidate in
53+
Solution(
54+
summary: "Replace \(token.singleQuoted) with \(candidate.singleQuoted).",
55+
replacements: []
56+
)
57+
}
58+
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions))
59+
}
60+
}
61+
}
62+
}

Sources/SwiftDocC/Infrastructure/DocumentationContext.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ public class DocumentationContext {
273273
MissingAbstract(sourceFile: source).any(),
274274
NonOverviewHeadingChecker(sourceFile: source).any(),
275275
SeeAlsoInTopicsHeadingChecker(sourceFile: source).any(),
276+
InvalidCodeBlockOption(sourceFile: source).any(),
276277
])
277278
checker.visit(document)
278279
diagnosticEngine.emit(checker.problems)
@@ -2457,7 +2458,6 @@ public class DocumentationContext {
24572458
}
24582459
}
24592460
}
2460-
24612461
/// A closure type getting the information about a reference in a context and returns any possible problems with it.
24622462
public typealias ReferenceCheck = (DocumentationContext, ResolvedTopicReference) -> [Problem]
24632463

Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,20 @@ extension DocumentationBundle.Info {
3737
self.unknownFeatureFlags = []
3838
}
3939

40+
/// This feature flag corresponds to ``FeatureFlags/isExperimentalCodeBlockAnnotationsEnabled``.
41+
public var experimentalCodeBlockAnnotations: Bool?
42+
43+
public init(experimentalCodeBlockAnnotations: Bool? = nil) {
44+
self.experimentalCodeBlockAnnotations = experimentalCodeBlockAnnotations
45+
self.unknownFeatureFlags = []
46+
}
47+
4048
/// A list of decoded feature flag keys that didn't match a known feature flag.
4149
public let unknownFeatureFlags: [String]
4250

4351
enum CodingKeys: String, CodingKey, CaseIterable {
4452
case experimentalOverloadedSymbolPresentation = "ExperimentalOverloadedSymbolPresentation"
53+
case experimentalCodeBlockAnnotations = "ExperimentalCodeBlockAnnotations"
4554
}
4655

4756
struct AnyCodingKeys: CodingKey {
@@ -66,6 +75,9 @@ extension DocumentationBundle.Info {
6675
switch codingKey {
6776
case .experimentalOverloadedSymbolPresentation:
6877
self.experimentalOverloadedSymbolPresentation = try values.decode(Bool.self, forKey: flagName)
78+
79+
case .experimentalCodeBlockAnnotations:
80+
self.experimentalCodeBlockAnnotations = try values.decode(Bool.self, forKey: flagName)
6981
}
7082
} else {
7183
unknownFeatureFlags.append(flagName.stringValue)
@@ -79,6 +91,7 @@ extension DocumentationBundle.Info {
7991
var container = encoder.container(keyedBy: CodingKeys.self)
8092

8193
try container.encode(experimentalOverloadedSymbolPresentation, forKey: .experimentalOverloadedSymbolPresentation)
94+
try container.encode(experimentalCodeBlockAnnotations, forKey: .experimentalCodeBlockAnnotations)
8295
}
8396
}
8497
}

Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,26 @@ public enum RenderBlockContent: Equatable {
124124
public var code: [String]
125125
/// Additional metadata for this code block.
126126
public var metadata: RenderContentMetadata?
127+
public var copyToClipboard: Bool
128+
129+
public enum OptionName: String, CaseIterable {
130+
case nocopy
131+
132+
init?(caseInsensitive raw: some StringProtocol) {
133+
self.init(rawValue: raw.lowercased())
134+
}
135+
}
136+
137+
public static var knownOptions: Set<String> {
138+
Set(OptionName.allCases.map(\.rawValue))
139+
}
127140

128141
/// Make a new `CodeListing` with the given data.
129-
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?) {
142+
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled) {
130143
self.syntax = syntax
131144
self.code = code
132145
self.metadata = metadata
146+
self.copyToClipboard = copyToClipboard
133147
}
134148
}
135149

@@ -697,7 +711,7 @@ extension RenderBlockContent.Table: Codable {
697711
extension RenderBlockContent: Codable {
698712
private enum CodingKeys: CodingKey {
699713
case type
700-
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start
714+
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard
701715
case request, response
702716
case header, rows
703717
case numberOfColumns, columns
@@ -719,11 +733,13 @@ extension RenderBlockContent: Codable {
719733
}
720734
self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content)))
721735
case .codeListing:
736+
let copy = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled
722737
self = try .codeListing(.init(
723738
syntax: container.decodeIfPresent(String.self, forKey: .syntax),
724739
code: container.decode([String].self, forKey: .code),
725-
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
726-
))
740+
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata),
741+
copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy
742+
))
727743
case .heading:
728744
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)))
729745
case .orderedList:
@@ -826,6 +842,7 @@ extension RenderBlockContent: Codable {
826842
try container.encode(l.syntax, forKey: .syntax)
827843
try container.encode(l.code, forKey: .code)
828844
try container.encodeIfPresent(l.metadata, forKey: .metadata)
845+
try container.encode(l.copyToClipboard, forKey: .copyToClipboard)
829846
case .heading(let h):
830847
try container.encode(h.level, forKey: .level)
831848
try container.encode(h.text, forKey: .text)

Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,41 @@ struct RenderContentCompiler: MarkupVisitor {
4747

4848
mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> [any RenderContent] {
4949
// Default to the bundle's code listing syntax if one is not explicitly declared in the code block.
50-
return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil))]
50+
51+
if FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled {
52+
53+
func parseLanguageString(_ input: String?) -> (lang: String? , tokens: [RenderBlockContent.CodeListing.OptionName]) {
54+
guard let input else { return (lang: nil, tokens: []) }
55+
let parts = input
56+
.split(separator: ",")
57+
.map { $0.trimmingCharacters(in: .whitespaces) }
58+
var lang: String? = nil
59+
var options: [RenderBlockContent.CodeListing.OptionName] = []
60+
61+
for part in parts {
62+
if let opt = RenderBlockContent.CodeListing.OptionName(caseInsensitive: part) {
63+
options.append(opt)
64+
} else if lang == nil {
65+
lang = String(part)
66+
}
67+
}
68+
return (lang, options)
69+
}
70+
71+
let options = parseLanguageString(codeBlock.language)
72+
73+
let listing = RenderBlockContent.CodeListing(
74+
syntax: options.lang ?? bundle.info.defaultCodeListingLanguage,
75+
code: codeBlock.code.splitByNewlines,
76+
metadata: nil,
77+
copyToClipboard: !options.tokens.contains(.nocopy)
78+
)
79+
80+
return [RenderBlockContent.codeListing(listing)]
81+
82+
} else {
83+
return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: false))]
84+
}
5185
}
5286

5387
mutating func visitHeading(_ heading: Heading) -> [any RenderContent] {

Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,9 @@
805805
},
806806
"metadata": {
807807
"$ref": "#/components/schemas/RenderContentMetadata"
808+
},
809+
"copyToClipboard": {
810+
"type": "boolean"
808811
}
809812
}
810813
},

Sources/SwiftDocC/Utility/FeatureFlags.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@ public struct FeatureFlags: Codable {
1313
/// The current feature flags that Swift-DocC uses to conditionally enable
1414
/// (usually experimental) behavior in Swift-DocC.
1515
public static var current = FeatureFlags()
16-
16+
17+
/// Whether or not experimental annotation of code blocks is enabled.
18+
public var isExperimentalCodeBlockAnnotationsEnabled = false
19+
1720
/// Whether or not experimental support for device frames on images and video is enabled.
1821
public var isExperimentalDeviceFrameSupportEnabled = false
1922

Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ extension ConvertAction {
1919
public init(fromConvertCommand convert: Docc.Convert, withFallbackTemplate fallbackTemplateURL: URL? = nil) throws {
2020
var standardError = LogHandle.standardError
2121
let outOfProcessResolver: OutOfProcessReferenceResolver?
22-
22+
FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled = convert.featureFlags.enableExperimentalCodeBlockAnnotations
2323
FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled = convert.enableExperimentalDeviceFrameSupport
2424
FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = convert.enableExperimentalLinkHierarchySerialization
2525
FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation

Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,13 @@ extension Docc {
475475
struct FeatureFlagOptions: ParsableArguments {
476476
@Flag(help: "Allows for custom templates, like `header.html`.")
477477
var experimentalEnableCustomTemplates = false
478-
478+
479+
@Flag(
480+
name: .customLong("enable-experimental-code-block-annotations"),
481+
help: "Support annotations for code blocks."
482+
)
483+
var enableExperimentalCodeBlockAnnotations = false
484+
479485
@Flag(help: .hidden)
480486
var enableExperimentalDeviceFrameSupport = false
481487

@@ -558,6 +564,14 @@ extension Docc {
558564

559565
}
560566

567+
/// A user-provided value that is true if the user enables experimental support for code block annotation.
568+
///
569+
/// Defaults to false.
570+
public var enableExperimentalCodeBlocAnnotations: Bool {
571+
get { featureFlags.enableExperimentalCodeBlockAnnotations }
572+
set { featureFlags.enableExperimentalCodeBlockAnnotations = newValue}
573+
}
574+
561575
/// A user-provided value that is true if the user enables experimental support for device frames.
562576
///
563577
/// Defaults to false.
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2025 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 XCTest
12+
@testable import SwiftDocC
13+
import Markdown
14+
15+
class InvalidCodeBlockOptionTests: XCTestCase {
16+
17+
func testNoOptions() {
18+
let markupSource = """
19+
```
20+
let a = 1
21+
```
22+
"""
23+
let document = Document(parsing: markupSource, options: [])
24+
var checker = InvalidCodeBlockOption(sourceFile: nil)
25+
checker.visit(document)
26+
XCTAssertTrue(checker.problems.isEmpty)
27+
XCTAssertEqual(RenderBlockContent.CodeListing.knownOptions, ["nocopy"])
28+
}
29+
30+
func testOption() {
31+
let markupSource = """
32+
```nocopy
33+
let a = 1
34+
```
35+
"""
36+
let document = Document(parsing: markupSource, options: [])
37+
var checker = InvalidCodeBlockOption(sourceFile: nil)
38+
checker.visit(document)
39+
XCTAssertTrue(checker.problems.isEmpty)
40+
}
41+
42+
func testMultipleOptionTypos() {
43+
let markupSource = """
44+
```nocoy
45+
let b = 2
46+
```
47+
48+
```nocoy
49+
let c = 3
50+
```
51+
"""
52+
let document = Document(parsing: markupSource, options: [])
53+
var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file))
54+
checker.visit(document)
55+
XCTAssertEqual(2, checker.problems.count)
56+
57+
for problem in checker.problems {
58+
XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", problem.diagnostic.identifier)
59+
XCTAssertEqual(problem.diagnostic.summary, "Unknown option 'nocoy' in code block.")
60+
XCTAssertEqual(problem.possibleSolutions.map(\.summary), ["Replace 'nocoy' with 'nocopy'."])
61+
}
62+
}
63+
64+
func testOptionDifferentTypos() throws {
65+
let markupSource = """
66+
```swift, nocpy
67+
let d = 4
68+
```
69+
70+
```unknown, nocpoy
71+
let e = 5
72+
```
73+
74+
```nocopy
75+
let f = 6
76+
```
77+
78+
```ncopy
79+
let g = 7
80+
```
81+
"""
82+
let document = Document(parsing: markupSource, options: [])
83+
var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file))
84+
checker.visit(document)
85+
86+
XCTAssertEqual(3, checker.problems.count)
87+
88+
let summaries = checker.problems.map { $0.diagnostic.summary }
89+
XCTAssertEqual(summaries, [
90+
"Unknown option 'nocpy' in code block.",
91+
"Unknown option 'nocpoy' in code block.",
92+
"Unknown option 'ncopy' in code block.",
93+
])
94+
95+
for problem in checker.problems {
96+
XCTAssertEqual(
97+
"org.swift.docc.InvalidCodeBlockOption",
98+
problem.diagnostic.identifier
99+
)
100+
101+
XCTAssertEqual(problem.possibleSolutions.count, 1)
102+
let solution = try XCTUnwrap(problem.possibleSolutions.first)
103+
XCTAssert(solution.summary.hasSuffix("with 'nocopy'."))
104+
105+
}
106+
}
107+
}
108+

0 commit comments

Comments
 (0)