diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 83a9aabba..3da913365 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -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] diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift b/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift index dd62465dd..0301ec714 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/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 { @@ -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) @@ -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) } } } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 7c4695f2a..4eb4e638b 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -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 } } @@ -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 @@ -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: @@ -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) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 58fabcced..fa9f1725c 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.isExperimentalCodeBlockEnabled { + + struct ParsedOptions { + var lang: String? + var nocopy = 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 == "nocopy" { + options.nocopy = 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.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] { diff --git a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift index a2188a3c8..5497f9f19 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -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] } } diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 4ced31500..628a2d98f 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 b2ec4dbc5..77f7ddc9f 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 isExperimentalCodeBlockEnabled = 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 e8c8a31b4..6aa01aff3 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.isExperimentalCodeBlockEnabled = convert.enableExperimentalCodeBlock 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 557b4f2a5..df9ad8c23 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"), + help: "Support copy-to-clipboard for code blocks." + ) + var enableExperimentalCodeBlock = false + @Flag(help: .hidden) var enableExperimentalDeviceFrameSupport = false @@ -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. diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 23da7c124..3d7d0c44e 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 d539531a3..0f669cd1c 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 8a23b1324..4a40e300b 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(\.isExperimentalCodeBlockEnabled) + + 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) + + 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 59fe23e12..cdf61e5f3 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 a14d784fe..31e8b0e7d 100644 --- a/features.json +++ b/features.json @@ -1,5 +1,8 @@ { "features": [ + { + "name": "code-blocks" + }, { "name": "diagnostics-file" },