From 0c983cde122ac6e7ca13010891c920f19edd0d60 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 28 Jul 2025 17:36:18 -0600 Subject: [PATCH] Add copy-to-clipboard support for code blocks (with 'nocopy' annotation to disable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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` --- .../Checkers/InvalidCodeBlockOption.swift | 62 ++++++++++ .../Infrastructure/DocumentationContext.swift | 2 +- .../Workspace/FeatureFlags+Info.swift | 13 +++ .../Content/RenderBlockContent.swift | 25 +++- .../Rendering/RenderContentCompiler.swift | 36 +++++- .../Resources/RenderNode.spec.json | 3 + Sources/SwiftDocC/Utility/FeatureFlags.swift | 5 +- .../ConvertAction+CommandInitialization.swift | 2 +- .../ArgumentParsing/Subcommands/Convert.swift | 16 ++- .../InvalidCodeBlockOptionTests.swift | 108 ++++++++++++++++++ .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +- .../RenderContentCompilerTests.swift | 93 +++++++++++++++ .../Utility/ListItemExtractorTests.swift | 4 +- features.json | 3 + 15 files changed, 367 insertions(+), 17 deletions(-) create mode 100644 Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift create mode 100644 Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift new file mode 100644 index 0000000000..ca8bd2cb5b --- /dev/null +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -0,0 +1,62 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 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 +*/ + +internal import Foundation +internal import Markdown + +/** + Code blocks can have a `nocopy` option after the \`\`\`, in the language line. +`nocopy` can be immediately after the \`\`\` or after a specified language and a comma (`,`). + */ +internal struct InvalidCodeBlockOption: Checker { + var problems = [Problem]() + + /// Parsing options for code blocks + private let knownOptions = RenderBlockContent.CodeListing.knownOptions + + private var sourceFile: URL? + + /// Creates a new checker that detects documents with multiple titles. + /// + /// - Parameter sourceFile: The URL to the documentation file that the checker checks. + init(sourceFile: URL?) { + self.sourceFile = sourceFile + } + + mutating func visitCodeBlock(_ codeBlock: CodeBlock) { + let info = codeBlock.language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !info.isEmpty else { return } + + let tokens = info + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + + guard !tokens.isEmpty else { return } + + for token in tokens { + // if the token is an exact match, we don't need to do anything + guard !knownOptions.contains(token) else { continue } + + let matches = NearMiss.bestMatches(for: knownOptions, against: token) + + if !matches.isEmpty { + let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(token.singleQuoted) in code block.") + let possibleSolutions = matches.map { candidate in + Solution( + summary: "Replace \(token.singleQuoted) with \(candidate.singleQuoted).", + replacements: [] + ) + } + problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions)) + } + } + } +} diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index b4200ae07d..87fd41cd1c 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -273,6 +273,7 @@ public class DocumentationContext { MissingAbstract(sourceFile: source).any(), NonOverviewHeadingChecker(sourceFile: source).any(), SeeAlsoInTopicsHeadingChecker(sourceFile: source).any(), + InvalidCodeBlockOption(sourceFile: source).any(), ]) checker.visit(document) diagnosticEngine.emit(checker.problems) @@ -2457,7 +2458,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] diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift b/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift index dd62465ddd..4f95feb9b2 100644 --- a/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift +++ b/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift @@ -37,11 +37,20 @@ extension DocumentationBundle.Info { self.unknownFeatureFlags = [] } + /// This feature flag corresponds to ``FeatureFlags/isExperimentalCodeBlockAnnotationsEnabled``. + public var experimentalCodeBlockAnnotations: Bool? + + public init(experimentalCodeBlockAnnotations: Bool? = nil) { + self.experimentalCodeBlockAnnotations = experimentalCodeBlockAnnotations + 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 experimentalCodeBlockAnnotations = "ExperimentalCodeBlockAnnotations" } struct AnyCodingKeys: CodingKey { @@ -66,6 +75,9 @@ extension DocumentationBundle.Info { switch codingKey { case .experimentalOverloadedSymbolPresentation: self.experimentalOverloadedSymbolPresentation = try values.decode(Bool.self, forKey: flagName) + + case .experimentalCodeBlockAnnotations: + self.experimentalCodeBlockAnnotations = try values.decode(Bool.self, forKey: flagName) } } else { unknownFeatureFlags.append(flagName.stringValue) @@ -79,6 +91,7 @@ extension DocumentationBundle.Info { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(experimentalOverloadedSymbolPresentation, forKey: .experimentalOverloadedSymbolPresentation) + try container.encode(experimentalCodeBlockAnnotations, forKey: .experimentalCodeBlockAnnotations) } } } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 7c4695f2a6..85ac031825 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -124,12 +124,26 @@ public enum RenderBlockContent: Equatable { public var code: [String] /// Additional metadata for this code block. public var metadata: RenderContentMetadata? + public var copyToClipboard: Bool + + public enum OptionName: String, CaseIterable { + case nocopy + + init?(caseInsensitive raw: some StringProtocol) { + self.init(rawValue: raw.lowercased()) + } + } + + public static var knownOptions: Set { + Set(OptionName.allCases.map(\.rawValue)) + } /// 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 = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled) { self.syntax = syntax self.code = code self.metadata = metadata + self.copyToClipboard = copyToClipboard } } @@ -697,7 +711,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 @@ -719,11 +733,13 @@ extension RenderBlockContent: Codable { } self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content))) case .codeListing: + let copy = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled 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: @@ -826,6 +842,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) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 58fabccede..e79e6fdfcd 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -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.isExperimentalCodeBlockAnnotationsEnabled { + + func parseLanguageString(_ input: String?) -> (lang: String? , tokens: [RenderBlockContent.CodeListing.OptionName]) { + guard let input else { return (lang: nil, tokens: []) } + let parts = input + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespaces) } + var lang: String? = nil + var options: [RenderBlockContent.CodeListing.OptionName] = [] + + for part in parts { + if let opt = RenderBlockContent.CodeListing.OptionName(caseInsensitive: part) { + options.append(opt) + } else if lang == nil { + lang = String(part) + } + } + return (lang, options) + } + + let options = parseLanguageString(codeBlock.language) + + let listing = RenderBlockContent.CodeListing( + syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, + code: codeBlock.code.splitByNewlines, + metadata: nil, + copyToClipboard: !options.tokens.contains(.nocopy) + ) + + return [RenderBlockContent.codeListing(listing)] + + } 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] { diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 4ced315007..628a2d98f6 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -805,6 +805,9 @@ }, "metadata": { "$ref": "#/components/schemas/RenderContentMetadata" + }, + "copyToClipboard": { + "type": "boolean" } } }, diff --git a/Sources/SwiftDocC/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index def7e642d1..538e55781f 100644 --- a/Sources/SwiftDocC/Utility/FeatureFlags.swift +++ b/Sources/SwiftDocC/Utility/FeatureFlags.swift @@ -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 isExperimentalCodeBlockAnnotationsEnabled = false + /// Whether or not experimental support for device frames on images and video is enabled. public var isExperimentalDeviceFrameSupportEnabled = false diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index e8c8a31b45..c23c8c55bb 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -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.isExperimentalCodeBlockAnnotationsEnabled = convert.featureFlags.enableExperimentalCodeBlockAnnotations FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled = convert.enableExperimentalDeviceFrameSupport FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = convert.enableExperimentalLinkHierarchySerialization FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 95b0d098c5..aa427bdb1c 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -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-annotations"), + help: "Support annotations for code blocks." + ) + var enableExperimentalCodeBlockAnnotations = false + @Flag(help: .hidden) var enableExperimentalDeviceFrameSupport = false @@ -558,6 +564,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 enableExperimentalCodeBlocAnnotations: Bool { + get { featureFlags.enableExperimentalCodeBlockAnnotations } + set { featureFlags.enableExperimentalCodeBlockAnnotations = newValue} + } + /// A user-provided value that is true if the user enables experimental support for device frames. /// /// Defaults to false. diff --git a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift new file mode 100644 index 0000000000..2def720f13 --- /dev/null +++ b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift @@ -0,0 +1,108 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 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 XCTest +@testable import SwiftDocC +import Markdown + +class InvalidCodeBlockOptionTests: XCTestCase { + + func testNoOptions() { + let markupSource = """ +``` +let a = 1 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: nil) + checker.visit(document) + XCTAssertTrue(checker.problems.isEmpty) + XCTAssertEqual(RenderBlockContent.CodeListing.knownOptions, ["nocopy"]) + } + + func testOption() { + let markupSource = """ +```nocopy +let a = 1 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: nil) + checker.visit(document) + XCTAssertTrue(checker.problems.isEmpty) + } + + func testMultipleOptionTypos() { + let markupSource = """ +```nocoy +let b = 2 +``` + +```nocoy +let c = 3 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + XCTAssertEqual(2, checker.problems.count) + + for problem in checker.problems { + XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", problem.diagnostic.identifier) + XCTAssertEqual(problem.diagnostic.summary, "Unknown option 'nocoy' in code block.") + XCTAssertEqual(problem.possibleSolutions.map(\.summary), ["Replace 'nocoy' with 'nocopy'."]) + } + } + + func testOptionDifferentTypos() throws { + let markupSource = """ +```swift, nocpy +let d = 4 +``` + +```unknown, nocpoy +let e = 5 +``` + +```nocopy +let f = 6 +``` + +```ncopy +let g = 7 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + + XCTAssertEqual(3, checker.problems.count) + + let summaries = checker.problems.map { $0.diagnostic.summary } + XCTAssertEqual(summaries, [ + "Unknown option 'nocpy' in code block.", + "Unknown option 'nocpoy' in code block.", + "Unknown option 'ncopy' in code block.", + ]) + + for problem in checker.problems { + XCTAssertEqual( + "org.swift.docc.InvalidCodeBlockOption", + problem.diagnostic.identifier + ) + + XCTAssertEqual(problem.possibleSolutions.count, 1) + let solution = try XCTUnwrap(problem.possibleSolutions.first) + XCTAssert(solution.summary.hasSuffix("with 'nocopy'.")) + + } + } +} + diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 23da7c1241..3d7d0c44ea 100644 --- a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift +++ b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift @@ -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) diff --git a/Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift b/Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift index d539531a31..0f669cd1cd 100644 --- a/Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift +++ b/Tests/SwiftDocCTests/Model/RenderNodeSerializationTests.swift @@ -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)), ])) ] @@ -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!"), ]) diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index 8a23b1324a..ef3604fd9c 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -223,4 +223,97 @@ class RenderContentCompilerTests: XCTestCase { XCTAssertEqual(documentThematicBreak, thematicBreak) } } + + func testCopyToClipboard() async throws { + enableFeatureFlag(\.isExperimentalCodeBlockAnnotationsEnabled) + + 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(\.isExperimentalCodeBlockAnnotationsEnabled) + + 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) + } } diff --git a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift index 59fe23e12b..cdf61e5f3d 100644 --- a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift +++ b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift @@ -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: [ diff --git a/features.json b/features.json index a14d784fe4..31e8b0e7d3 100644 --- a/features.json +++ b/features.json @@ -1,5 +1,8 @@ { "features": [ + { + "name": "code-blocks" + }, { "name": "diagnostics-file" },