From d579066fd7a4f6273e8a63dca08bb6d80c3ab1ed Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 28 Jul 2025 17:36:18 -0600 Subject: [PATCH 01/39] WIP add copy button to CodeListing --- .../Model/Rendering/Content/RenderBlockContent.swift | 9 ++++++--- .../Model/Rendering/RenderContentCompiler.swift | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 7c4695f2a..bff9a6902 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 = 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 } } @@ -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 @@ -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.decode(Bool.self, forKey: .copyToClipboard) )) 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))) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 58fabcced..f9d951ec4 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -47,7 +47,7 @@ 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))] + 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] { From f2aa602bbc9ff5e224903e8f123d77f99da30cf7 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 30 Jul 2025 13:57:41 -0600 Subject: [PATCH 02/39] update OpenAPI spec with copyToClipboard on CodeListing --- .../SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json | 3 +++ 1 file changed, 3 insertions(+) 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" } } }, From 11ce8e944b2fba822b8a934f0072a5d1d7212d74 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Thu, 31 Jul 2025 15:10:16 -0600 Subject: [PATCH 03/39] Add option to copy a code block parsed from the language string, delimited by commas --- .../Rendering/RenderContentCompiler.swift | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index f9d951ec4..4b561a99b 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -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, copyToClipboard: false))] + 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] { From 3943457ade51ebb1b3a61077e977609a1373577a Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Thu, 31 Jul 2025 16:07:55 -0600 Subject: [PATCH 04/39] fix unit tests --- .../Model/Rendering/Content/RenderBlockContent.swift | 3 ++- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +++++----- .../Utility/ListItemExtractorTests.swift | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index bff9a6902..fc47bdd97 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -725,7 +725,7 @@ extension RenderBlockContent: Codable { syntax: container.decodeIfPresent(String.self, forKey: .syntax), code: container.decode([String].self, forKey: .code), metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata), - copyToClipboard: container.decode(Bool.self, forKey: .copyToClipboard) + 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))) @@ -829,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) 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/Utility/ListItemExtractorTests.swift b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift index 59fe23e12..3e5879c26 100644 --- a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift +++ b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift @@ -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: [ From 4d3ae4633f7cfb4c96f786080b49079ada6adc11 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Thu, 31 Jul 2025 18:01:53 -0600 Subject: [PATCH 05/39] add unit test for copyToClipboard --- .../RenderContentCompilerTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index 8a23b1324..9f61495b7 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -223,4 +223,26 @@ class RenderContentCompilerTests: XCTestCase { XCTAssertEqual(documentThematicBreak, thematicBreak) } } + + func testCopyToClipboard() throws { + let (bundle, context) = try 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) + } } From 6935f87219e1d9eadd51d6e7594d58cc2122c8f8 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Tue, 5 Aug 2025 11:08:24 -0600 Subject: [PATCH 06/39] fixup missing async on test --- .../SwiftDocCTests/Rendering/RenderContentCompilerTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index 9f61495b7..396d4cee6 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -224,8 +224,8 @@ class RenderContentCompilerTests: XCTestCase { } } - func testCopyToClipboard() throws { - let (bundle, context) = try testBundleAndContext() + 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 = #""" From 59dc98956e4bea9f6584ebcbd40fea83ba6508d8 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 6 Aug 2025 13:38:47 -0600 Subject: [PATCH 07/39] update docs --- .../formatting-your-documentation-content.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md index 07d44ee0c..7e0b6dd41 100644 --- a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md +++ b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md @@ -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: From d7b1268c9bffd8c73e6b9b8d2b360b82febfb74b Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 11 Aug 2025 11:20:04 -0600 Subject: [PATCH 08/39] copy by default --- .../Content/RenderBlockContent.swift | 4 ++-- .../Rendering/RenderContentCompiler.swift | 8 +++---- .../formatting-your-documentation-content.md | 17 +++++++------ .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 ++++---- .../RenderContentCompilerTests.swift | 24 ++++++++++++++++++- .../Utility/ListItemExtractorTests.swift | 4 ++-- 7 files changed, 47 insertions(+), 22 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index fc47bdd97..fd8fbb6b3 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -124,7 +124,7 @@ public enum RenderBlockContent: Equatable { public var code: [String] /// Additional metadata for this code block. public var metadata: RenderContentMetadata? - public var copyToClipboard: Bool = false + public var copyToClipboard: Bool = true /// Make a new `CodeListing` with the given data. public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool) { @@ -725,7 +725,7 @@ extension RenderBlockContent: Codable { syntax: container.decodeIfPresent(String.self, forKey: .syntax), code: container.decode([String].self, forKey: .code), metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata), - copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? false + copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? true )) 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))) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 4b561a99b..dbf085d75 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -49,7 +49,7 @@ struct RenderContentCompiler: MarkupVisitor { // Default to the bundle's code listing syntax if one is not explicitly declared in the code block. struct ParsedOptions { var lang: String? - var copy = false + var nocopy = false } func parseLanguageString(_ input: String?) -> ParsedOptions { @@ -63,8 +63,8 @@ struct RenderContentCompiler: MarkupVisitor { for part in parts { let lower = part.lowercased() - if lower == "copy" { - options.copy = true + if lower == "nocopy" { + options.nocopy = true } else if options.lang == nil { options.lang = part } @@ -74,7 +74,7 @@ struct RenderContentCompiler: MarkupVisitor { let options = parseLanguageString(codeBlock.language) - return [RenderBlockContent.codeListing(.init(syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: options.copy))] + return [RenderBlockContent.codeListing(.init(syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: !options.nocopy))] } mutating func visitHeading(_ heading: Heading) -> [any RenderContent] { diff --git a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md index 7e0b6dd41..e2fb611ff 100644 --- a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md +++ b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md @@ -145,10 +145,17 @@ 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: +A copy-to-clipboard button is added to code listings by default behind the +feature flag `--enable-experimental-code-block`. +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. + +If you don't want a code block to have a copy-to-clipboard button, you can +include the `nocopy` option after the name of the programming language to +disable it for that code listing: -```swift, copy +```swift, nocopy struct Sightseeing: Activity { func perform(with sloth: inout Sloth) -> Speed { sloth.energyLevel -= 10 @@ -157,10 +164,6 @@ struct Sightseeing: Activity { } ``` -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: diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 3d7d0c44e..85c753bce 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, copyToClipboard: false)) + let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata, copyToClipboard: true)) 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 0f669cd1c..f44c3cf61 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, copyToClipboard: false)), + .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, copyToClipboard: true)), ])) ] @@ -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, 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: [.codeListing(.init(syntax: "swift", code: ["override func viewDidLoad() {", "super.viewDidLoad()", "}"], metadata: nil, copyToClipboard: true))], 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: true))], 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, 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: [.codeListing(.init(syntax: "swift", code: ["super.viewWillAppear()"], metadata: nil, copyToClipboard: true))], 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: true))], 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 396d4cee6..8f3514cf5 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -229,7 +229,7 @@ class RenderContentCompilerTests: XCTestCase { var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) let source = #""" - ```swift, copy + ```swift let x = 1 ``` """# @@ -245,4 +245,26 @@ class RenderContentCompilerTests: XCTestCase { XCTAssertEqual(codeListing.copyToClipboard, true) } + + func testNoCopyToClipboard() 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.copyToClipboard, false) + } } diff --git a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift index 3e5879c26..a5aae2441 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, copyToClipboard: false)), - + .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: true)), + // > Warning: Inner aside, with ``ThirdNotFoundSymbol`` link .aside(.init(style: .init(asideKind: .warning), content: [ .paragraph(.init(inlineContent: [ From 5d85dfa4f204c3f8a6de9e6367cb57148436e7e5 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 11 Aug 2025 15:30:50 -0600 Subject: [PATCH 09/39] add feature flag 'enable-experimental-code-block' for copy-to-clipboard and other code block annotations --- .../Infrastructure/DocumentationContext.swift | 1 - .../Workspace/FeatureFlags+Info.swift | 13 ++++++ .../Content/RenderBlockContent.swift | 8 +++- .../Rendering/RenderContentCompiler.swift | 43 +++++++++++-------- Sources/SwiftDocC/Utility/FeatureFlags.swift | 5 ++- .../ConvertAction+CommandInitialization.swift | 2 +- .../ArgumentParsing/Subcommands/Convert.swift | 16 ++++++- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 ++--- .../RenderContentCompilerTests.swift | 4 ++ .../Utility/ListItemExtractorTests.swift | 2 +- features.json | 3 ++ 12 files changed, 78 insertions(+), 31 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index b4200ae07..beef6d922 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -2457,7 +2457,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 fd8fbb6b3..b729be360 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -721,12 +721,16 @@ extension RenderBlockContent: Codable { } self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content))) case .codeListing: + var copy = false + if FeatureFlags.current.isExperimentalCodeBlockEnabled { + copy = true + } 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), - copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? true - )) + 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: diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index dbf085d75..fa9f1725c 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -47,34 +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. - struct ParsedOptions { - var lang: String? - var nocopy = false - } - func parseLanguageString(_ input: String?) -> ParsedOptions { - guard let input else { return ParsedOptions() } + 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 + let parts = input .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } - var options = ParsedOptions() + 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 + for part in parts { + let lower = part.lowercased() + if lower == "nocopy" { + options.nocopy = true + } else if options.lang == nil { + options.lang = part + } } + return options } - return options - } - let options = parseLanguageString(codeBlock.language) + let options = parseLanguageString(codeBlock.language) + + return [RenderBlockContent.codeListing(.init(syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: !options.nocopy))] - 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/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index def7e642d..df1f87dee 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 95b0d098c..4f36205c7 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: "Annotate code blocks with additional metadata to support copy-to-clipboard, highlighting, and wrapping on code blocks." + ) + var enableExperimentalCodeBlock = 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 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 85c753bce..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, copyToClipboard: true)) + 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 f44c3cf61..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, copyToClipboard: true)), + .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, copyToClipboard: true))], 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: true))], 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, copyToClipboard: true))], 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: true))], 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 8f3514cf5..3949b037a 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -225,6 +225,8 @@ class RenderContentCompilerTests: XCTestCase { } 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)) @@ -247,6 +249,8 @@ class RenderContentCompilerTests: XCTestCase { } 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)) diff --git a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift index a5aae2441..cdf61e5f3 100644 --- a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift +++ b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift @@ -514,7 +514,7 @@ class ListItemExtractorTests: XCTestCase { // ``` // Inner code block // ``` - .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: true)), + .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: [ 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" }, From 4d427dab51c1315ef6e1b98f8b6442bcfa50eeea Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 15 Aug 2025 15:34:21 -0600 Subject: [PATCH 10/39] add more tests, remove docs, code cleanup --- .../Content/RenderBlockContent.swift | 5 +-- .../ArgumentParsing/Subcommands/Convert.swift | 2 +- .../formatting-your-documentation-content.md | 21 --------- .../RenderContentCompilerTests.swift | 45 +++++++++++++++++++ 4 files changed, 47 insertions(+), 26 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index b729be360..4eb4e638b 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -721,10 +721,7 @@ extension RenderBlockContent: Codable { } self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content))) case .codeListing: - var copy = false - if FeatureFlags.current.isExperimentalCodeBlockEnabled { - copy = true - } + let copy = FeatureFlags.current.isExperimentalCodeBlockEnabled self = try .codeListing(.init( syntax: container.decodeIfPresent(String.self, forKey: .syntax), code: container.decode([String].self, forKey: .code), diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index 4f36205c7..1e090f2a6 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -478,7 +478,7 @@ extension Docc { @Flag( name: .customLong("enable-experimental-code-block"), - help: "Annotate code blocks with additional metadata to support copy-to-clipboard, highlighting, and wrapping on code blocks." + help: "Support copy-to-clipboard for code blocks." ) var enableExperimentalCodeBlock = false diff --git a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md index e2fb611ff..07d44ee0c 100644 --- a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md +++ b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md @@ -143,27 +143,6 @@ 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 - -A copy-to-clipboard button is added to code listings by default behind the -feature flag `--enable-experimental-code-block`. -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. - -If you don't want a code block to have a copy-to-clipboard button, you can -include the `nocopy` option after the name of the programming language to -disable it for that code listing: - -```swift, nocopy -struct Sightseeing: Activity { - func perform(with sloth: inout Sloth) -> Speed { - sloth.energyLevel -= 10 - return .slow - } -} -``` - DocC uses the programming language you specify to apply the correct syntax color formatting. For the example above, DocC generates the following: diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index 3949b037a..4a40e300b 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -271,4 +271,49 @@ class RenderContentCompilerTests: XCTestCase { 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) + } } From f6ad87b3d39dcd42243aa877a2f463abd7d916f2 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 18 Aug 2025 17:00:54 -0600 Subject: [PATCH 11/39] WIP diagnostics --- .../Checkers/InvalidCodeBlockOption.swift | 58 +++++++++++++++++++ .../Infrastructure/DocumentationContext.swift | 1 + 2 files changed, 59 insertions(+) create mode 100644 Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift new file mode 100644 index 000000000..9a14c478e --- /dev/null +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -0,0 +1,58 @@ +/* + 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 +*/ + +public import Foundation +public 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 (`,`). + */ +public struct InvalidCodeBlockOption: Checker { + public var problems = [Problem]() + + // FIXME: populate this from the parse options + /// Parsing options for code blocks + private let knownOptions = ["nocopy"] + + 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. + public init(sourceFile: URL?) { + self.sourceFile = sourceFile + } + + public 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) in code block. Did you mean \(matches)?", explanation: nil) + // FIXME: figure out the position of 'token' and provide solutions + problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) + } + } + } +} diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index beef6d922..87fd41cd1 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) From fe35cdc53d32ab8dcb9588a73e6cb5d19f260d77 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 20 Aug 2025 11:01:19 -0600 Subject: [PATCH 12/39] WIP diagnostics: tests --- .../Checkers/InvalidCodeBlockOption.swift | 10 +- .../InvalidCodeBlockOptionTests.swift | 95 +++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) 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 index 9a14c478e..4a6f3586e 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -49,9 +49,15 @@ public struct InvalidCodeBlockOption: Checker { 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) in code block. Did you mean \(matches)?", explanation: nil) + 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: [] + ) + } // FIXME: figure out the position of 'token' and provide solutions - problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) + problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions)) } } } diff --git a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift new file mode 100644 index 000000000..a1f3c9d4a --- /dev/null +++ b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift @@ -0,0 +1,95 @@ +/* + 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 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'.")) + + } + } +} + From c9b8a52876344c2b328c7f7574e6f0df55fd66c4 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Thu, 21 Aug 2025 15:40:05 -0600 Subject: [PATCH 13/39] add code block options onto RenderBlockContent.CodeListing --- .../Checkers/InvalidCodeBlockOption.swift | 4 +- .../Content/RenderBlockContent.swift | 12 ++++++ .../Rendering/RenderContentCompiler.swift | 42 +++++++++++-------- .../InvalidCodeBlockOptionTests.swift | 13 ++++++ 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift index 4a6f3586e..56e546edc 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -18,9 +18,8 @@ public import Markdown public struct InvalidCodeBlockOption: Checker { public var problems = [Problem]() - // FIXME: populate this from the parse options /// Parsing options for code blocks - private let knownOptions = ["nocopy"] + private let knownOptions = RenderBlockContent.CodeListing.knownOptions private var sourceFile: URL? @@ -56,7 +55,6 @@ public struct InvalidCodeBlockOption: Checker { replacements: [] ) } - // FIXME: figure out the position of 'token' and provide solutions problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions)) } } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 4eb4e638b..cd83e693f 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -126,6 +126,18 @@ public enum RenderBlockContent: Equatable { public var metadata: RenderContentMetadata? public var copyToClipboard: Bool = true + public enum OptionName: String, CaseIterable { + case nocopy + + init?(caseInsensitive raw: S) { + 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?, copyToClipboard: Bool) { self.syntax = syntax diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index fa9f1725c..debc35d2b 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -50,34 +50,42 @@ struct RenderContentCompiler: MarkupVisitor { if FeatureFlags.current.isExperimentalCodeBlockEnabled { - struct ParsedOptions { - var lang: String? - var nocopy = false - } - - func parseLanguageString(_ input: String?) -> ParsedOptions { - guard let input else { return ParsedOptions() } - + 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 options = ParsedOptions() + var lang: String? = nil + var options: [RenderBlockContent.CodeListing.OptionName] = [] for part in parts { - let lower = part.lowercased() - if lower == "nocopy" { - options.nocopy = true - } else if options.lang == nil { - options.lang = part + if let opt = RenderBlockContent.CodeListing.OptionName(caseInsensitive: part) { + options.append(opt) + } else if lang == nil { + lang = String(part) } } - return options + return (lang, 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))] + var listing = RenderBlockContent.CodeListing( + syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, + code: codeBlock.code.splitByNewlines, + metadata: nil, + copyToClipboard: true // default value + ) + + // apply code block options + for option in options.tokens { + switch option { + case .nocopy: + listing.copyToClipboard = false + } + } + + return [RenderBlockContent.codeListing(listing)] } else { return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: false))] diff --git a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift index a1f3c9d4a..2def720f1 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift @@ -14,6 +14,19 @@ 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 From 775fd6813e1bee7110d699e2e92fe8dcc071d766 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 17 Sep 2025 12:01:09 -0600 Subject: [PATCH 14/39] rename feature flag --- .../Workspace/FeatureFlags+Info.swift | 16 ++++++++-------- .../Rendering/Content/RenderBlockContent.swift | 2 +- .../Model/Rendering/RenderContentCompiler.swift | 2 +- Sources/SwiftDocC/Utility/FeatureFlags.swift | 2 +- .../ConvertAction+CommandInitialization.swift | 2 +- .../ArgumentParsing/Subcommands/Convert.swift | 12 ++++++------ .../Rendering/RenderContentCompilerTests.swift | 4 ++-- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift b/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift index 0301ec714..4f95feb9b 100644 --- a/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift +++ b/Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift @@ -37,11 +37,11 @@ extension DocumentationBundle.Info { self.unknownFeatureFlags = [] } - /// This feature flag corresponds to ``FeatureFlags/isExperimentalCodeBlockEnabled``. - public var experimentalCodeBlock: Bool? + /// This feature flag corresponds to ``FeatureFlags/isExperimentalCodeBlockAnnotationsEnabled``. + public var experimentalCodeBlockAnnotations: Bool? - public init(experimentalCodeBlock: Bool? = nil) { - self.experimentalCodeBlock = experimentalCodeBlock + public init(experimentalCodeBlockAnnotations: Bool? = nil) { + self.experimentalCodeBlockAnnotations = experimentalCodeBlockAnnotations self.unknownFeatureFlags = [] } @@ -50,7 +50,7 @@ extension DocumentationBundle.Info { enum CodingKeys: String, CodingKey, CaseIterable { case experimentalOverloadedSymbolPresentation = "ExperimentalOverloadedSymbolPresentation" - case experimentalCodeBlock = "ExperimentalCodeBlock" + case experimentalCodeBlockAnnotations = "ExperimentalCodeBlockAnnotations" } struct AnyCodingKeys: CodingKey { @@ -76,8 +76,8 @@ extension DocumentationBundle.Info { case .experimentalOverloadedSymbolPresentation: self.experimentalOverloadedSymbolPresentation = try values.decode(Bool.self, forKey: flagName) - case .experimentalCodeBlock: - self.experimentalCodeBlock = try values.decode(Bool.self, forKey: flagName) + case .experimentalCodeBlockAnnotations: + self.experimentalCodeBlockAnnotations = try values.decode(Bool.self, forKey: flagName) } } else { unknownFeatureFlags.append(flagName.stringValue) @@ -91,7 +91,7 @@ extension DocumentationBundle.Info { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(experimentalOverloadedSymbolPresentation, forKey: .experimentalOverloadedSymbolPresentation) - try container.encode(experimentalCodeBlock, forKey: .experimentalCodeBlock) + 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 cd83e693f..ffe28ff26 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -733,7 +733,7 @@ extension RenderBlockContent: Codable { } self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content))) case .codeListing: - let copy = FeatureFlags.current.isExperimentalCodeBlockEnabled + let copy = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled self = try .codeListing(.init( syntax: container.decodeIfPresent(String.self, forKey: .syntax), code: container.decode([String].self, forKey: .code), diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index debc35d2b..9b12d87b2 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -48,7 +48,7 @@ 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. - if FeatureFlags.current.isExperimentalCodeBlockEnabled { + if FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled { func parseLanguageString(_ input: String?) -> (lang: String? , tokens: [RenderBlockContent.CodeListing.OptionName]) { guard let input else { return (lang: nil, tokens: []) } diff --git a/Sources/SwiftDocC/Utility/FeatureFlags.swift b/Sources/SwiftDocC/Utility/FeatureFlags.swift index df1f87dee..538e55781 100644 --- a/Sources/SwiftDocC/Utility/FeatureFlags.swift +++ b/Sources/SwiftDocC/Utility/FeatureFlags.swift @@ -15,7 +15,7 @@ public struct FeatureFlags: Codable { public static var current = FeatureFlags() /// Whether or not experimental annotation of code blocks is enabled. - public var isExperimentalCodeBlockEnabled = false + 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 6aa01aff3..c23c8c55b 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.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 1e090f2a6..aa427bdb1 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -477,10 +477,10 @@ extension Docc { var experimentalEnableCustomTemplates = false @Flag( - name: .customLong("enable-experimental-code-block"), - help: "Support copy-to-clipboard for code blocks." + name: .customLong("enable-experimental-code-block-annotations"), + help: "Support annotations for code blocks." ) - var enableExperimentalCodeBlock = false + var enableExperimentalCodeBlockAnnotations = false @Flag(help: .hidden) var enableExperimentalDeviceFrameSupport = false @@ -567,9 +567,9 @@ 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} + 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. diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index 4a40e300b..ef3604fd9 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -225,7 +225,7 @@ class RenderContentCompilerTests: XCTestCase { } func testCopyToClipboard() async throws { - enableFeatureFlag(\.isExperimentalCodeBlockEnabled) + 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)) @@ -249,7 +249,7 @@ class RenderContentCompilerTests: XCTestCase { } func testNoCopyToClipboard() async throws { - enableFeatureFlag(\.isExperimentalCodeBlockEnabled) + 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)) From b8712b529e89a6630ebfcd3d3b6a963cc50ab648 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 17 Sep 2025 12:01:36 -0600 Subject: [PATCH 15/39] remaining PR feedback --- .../Checker/Checkers/InvalidCodeBlockOption.swift | 12 ++++++------ .../Model/Rendering/Content/RenderBlockContent.swift | 6 +++--- .../Model/Rendering/RenderContentCompiler.swift | 12 ++---------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift index 56e546edc..ca8bd2cb5 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -8,15 +8,15 @@ See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ -public import Foundation -public import Markdown +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 (`,`). */ -public struct InvalidCodeBlockOption: Checker { - public var problems = [Problem]() +internal struct InvalidCodeBlockOption: Checker { + var problems = [Problem]() /// Parsing options for code blocks private let knownOptions = RenderBlockContent.CodeListing.knownOptions @@ -26,11 +26,11 @@ public struct InvalidCodeBlockOption: Checker { /// Creates a new checker that detects documents with multiple titles. /// /// - Parameter sourceFile: The URL to the documentation file that the checker checks. - public init(sourceFile: URL?) { + init(sourceFile: URL?) { self.sourceFile = sourceFile } - public mutating func visitCodeBlock(_ codeBlock: CodeBlock) { + mutating func visitCodeBlock(_ codeBlock: CodeBlock) { let info = codeBlock.language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !info.isEmpty else { return } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index ffe28ff26..8b4e05698 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -124,12 +124,12 @@ public enum RenderBlockContent: Equatable { public var code: [String] /// Additional metadata for this code block. public var metadata: RenderContentMetadata? - public var copyToClipboard: Bool = true + public var copyToClipboard: Bool public enum OptionName: String, CaseIterable { case nocopy - init?(caseInsensitive raw: S) { + init?(caseInsensitive raw: some StringProtocol) { self.init(rawValue: raw.lowercased()) } } @@ -139,7 +139,7 @@ public enum RenderBlockContent: Equatable { } /// Make a new `CodeListing` with the given data. - public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool) { + public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = true) { self.syntax = syntax self.code = code self.metadata = metadata diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 9b12d87b2..0ecd367c3 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -70,21 +70,13 @@ struct RenderContentCompiler: MarkupVisitor { let options = parseLanguageString(codeBlock.language) - var listing = RenderBlockContent.CodeListing( + let listing = RenderBlockContent.CodeListing( syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, - copyToClipboard: true // default value + copyToClipboard: options.tokens.contains(.nocopy) ) - // apply code block options - for option in options.tokens { - switch option { - case .nocopy: - listing.copyToClipboard = false - } - } - return [RenderBlockContent.codeListing(listing)] } else { From 00049e3b3fdb434e961607627252e29237a2d416 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 19 Sep 2025 08:52:56 -0600 Subject: [PATCH 16/39] copyToClipboard should be false when nocopy is present --- Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 0ecd367c3..e79e6fdfc 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -74,7 +74,7 @@ struct RenderContentCompiler: MarkupVisitor { syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, - copyToClipboard: options.tokens.contains(.nocopy) + copyToClipboard: !options.tokens.contains(.nocopy) ) return [RenderBlockContent.codeListing(listing)] From 7977adc522efafd73aabda9394aa47cc4f139b54 Mon Sep 17 00:00:00 2001 From: DebugSteven Date: Tue, 30 Sep 2025 18:06:19 -0600 Subject: [PATCH 17/39] init copyToClipboard based on feature flag presence --- .../SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 8b4e05698..85ac03182 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -139,7 +139,7 @@ public enum RenderBlockContent: Equatable { } /// Make a new `CodeListing` with the given data. - public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = true) { + public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled) { self.syntax = syntax self.code = code self.metadata = metadata From da78af6a0635ae54c7e12e6e8764bb5e2ba13769 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 11 Aug 2025 11:20:04 -0600 Subject: [PATCH 18/39] copy by default --- .../formatting-your-documentation-content.md | 90 +++++++++---------- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +-- 3 files changed, 51 insertions(+), 51 deletions(-) diff --git a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md index 07d44ee0c..9793f9717 100644 --- a/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md +++ b/Sources/docc/DocCDocumentation.docc/formatting-your-documentation-content.md @@ -4,35 +4,35 @@ Enhance your content's presentation with special formatting and styling for text ## Overview -Use [Markdown](https://daringfireball.net/projects/markdown/syntax), a -lightweight markup language, to give structure and style to your documentation. -DocC includes a custom dialect of Markdown, documentation markup, which -extends Markdown's syntax to include features like symbol linking, improved +Use [Markdown](https://daringfireball.net/projects/markdown/syntax), a +lightweight markup language, to give structure and style to your documentation. +DocC includes a custom dialect of Markdown, documentation markup, which +extends Markdown's syntax to include features like symbol linking, improved image support, term lists, and asides. -To ensure consistent structure and styling, use DocC's documentation markup for +To ensure consistent structure and styling, use DocC's documentation markup for all of the documentation you write. ### Add a Page Title and Section Headers -To add a page title, precede the text you want to use with a hash (`#`) and a +To add a page title, precede the text you want to use with a hash (`#`) and a space. For the page title of an article or API collection, use plain text only. ```markdown # Getting Started with Sloths ``` -> Important: Page titles must be the first line of content in a documentation +> Important: Page titles must be the first line of content in a documentation file. One or more empty lines can precede the page title. -For the page title of a landing page, enter a symbol link by wrapping the framework's +For the page title of a landing page, enter a symbol link by wrapping the framework's module name within a set of double backticks (\`\`). ```markdown # ``SlothCreator`` ``` -For a documentation extension file, enter a symbol link by wrapping the path to the symbol +For a documentation extension file, enter a symbol link by wrapping the path to the symbol within double backticks (\`\`). The path may start with the framework's module name or with the name of a top-level symbol in the module. @@ -48,41 +48,41 @@ The following example shows a documentation extension link to the same symbol st # ``CareSchedule/Event`` ``` -Augment every page title with a short and concise single-sentence abstract or -summary that provides additional information about the content. Add the summary +Augment every page title with a short and concise single-sentence abstract or +summary that provides additional information about the content. Add the summary using a new paragraph directly below the page title. ```markdown # Getting Started with Sloths Create a sloth and assign personality traits and abilities. -``` +``` -To add a header for an Overview or a Discussion section, use a double hash +To add a header for an Overview or a Discussion section, use a double hash (`##`) and a space, and then include either term in plain text. ```markdown ## Overview ``` -For all other section headers, use a triple hash (`###`) and a space, and then +For all other section headers, use a triple hash (`###`) and a space, and then add the title of the header in plain text. ```markdown ### Create a Sloth ``` -Use this type of section header in framework landing pages, top-level pages, -articles, and occasionally in symbol reference pages where you need to +Use this type of section header in framework landing pages, top-level pages, +articles, and occasionally in symbol reference pages where you need to provide more detail. ### Format Text in Bold, Italics, and Code Voice -DocC provides three ways to format the text in your documentation. You can -apply bold or italic styling, or you can use code voice, which renders the +DocC provides three ways to format the text in your documentation. You can +apply bold or italic styling, or you can use code voice, which renders the specified text in a monospace font. -To add bold styling, wrap the text in a pair of double asterisks (`**`). +To add bold styling, wrap the text in a pair of double asterisks (`**`). Alternatively, use double underscores (`__`). The following example uses bold styling for the names of the sloths: @@ -92,42 +92,42 @@ The following example uses bold styling for the names of the sloths: __Silly Sloth__: Prefers twigs for breakfast. ``` -Use italicized text to introduce new or alternative terms to the reader. To add -italic styling, wrap the text in a set of single underscores (`_`) or single +Use italicized text to introduce new or alternative terms to the reader. To add +italic styling, wrap the text in a set of single underscores (`_`) or single asterisks (`*`). -The following example uses italics for the words _metabolism_ and _habitat_: +The following example uses italics for the words _metabolism_ and _habitat_: ```markdown A sloth's _metabolism_ is highly dependent on its *habitat*. ``` -Use code voice to refer to symbols inline, or to include short code fragments, -such as class names or method signatures. To add code voice, wrap the text in +Use code voice to refer to symbols inline, or to include short code fragments, +such as class names or method signatures. To add code voice, wrap the text in a set of backticks (\`). -In the following example, DocC renders the words _ice_, _fire_, _wind_, and +In the following example, DocC renders the words _ice_, _fire_, _wind_, and _lightning_ in a monospace font: ```markdown -If your sloth possesses one of the special powers: `ice`, `fire`, +If your sloth possesses one of the special powers: `ice`, `fire`, `wind`, or `lightning`. ``` -> Note: To include multiple lines of code, use a code listing instead. For more +> Note: To include multiple lines of code, use a code listing instead. For more information, see . ### Add Code Listings -DocC includes support for code listings, or fenced code blocks, which allow you -to go beyond the basic declaration sections you find in symbol reference pages, -and to provide more complete code examples for adopters of your framework. You can -include code listings in your in-source symbol documentation, in extension +DocC includes support for code listings, or fenced code blocks, which allow you +to go beyond the basic declaration sections you find in symbol reference pages, +and to provide more complete code examples for adopters of your framework. You can +include code listings in your in-source symbol documentation, in extension files, and in articles and tutorials. -To create a code listing, start a new paragraph and add three backticks -(\`\`\`). Then, directly following the backticks, add the name of the -programming language in lowercase text. Add one or more lines of code, and then +To create a code listing, start a new paragraph and add three backticks +(\`\`\`). Then, directly following the backticks, add the name of the +programming language in lowercase text. Add one or more lines of code, and then add a new line and terminate the code listing by adding another three backticks: ```swift @@ -139,11 +139,11 @@ add a new line and terminate the code listing by adding another three backticks: } ``` -> Important: When formatting your code listing, use spaces to indent lines -instead of tabs so that DocC preserves the indentation when compiling your +> Important: When formatting your code listing, use spaces to indent lines +instead of tabs so that DocC preserves the indentation when compiling your documentation. -DocC uses the programming language you specify to apply the correct syntax +DocC uses the programming language you specify to apply the correct syntax color formatting. For the example above, DocC generates the following: ```swift @@ -191,12 +191,12 @@ DocC supports the following list types: | ------------- | ------------------------------------------------------ | | Bulleted list | Groups items that can appear in any order. | | Numbered list | Delineates a sequence of events in a particular order. | -| Term list | Defines a series of term-definition pairs. | +| Term list | Defines a series of term-definition pairs. | -> Important: Don't add images or code listings between list items. Bulleted and +> Important: Don't add images or code listings between list items. Bulleted and numbered lists must contain two or more items. -To create a bulleted list, precede each of the list's items with an asterisk (`*`) and a +To create a bulleted list, precede each of the list's items with an asterisk (`*`) and a space. Alternatively, use a dash (`-`) or a plus sign (`+`) instead of an asterisk (`*`); the list markers are interchangeable. ```markdown @@ -206,7 +206,7 @@ space. Alternatively, use a dash (`-`) or a plus sign (`+`) instead of an asteri + Lightning ``` -To create a numbered list, precede each of the list's items with the number of the step, then a period (`.`) and a space. +To create a numbered list, precede each of the list's items with the number of the step, then a period (`.`) and a space. ```markdown 1. Give the sloth some food. @@ -215,8 +215,8 @@ To create a numbered list, precede each of the list's items with the number of t 4. Put the sloth to bed. ``` -To create a term list, precede each term with a dash (`-`) and a -space, the `term` keyword, and another space. Then add a colon (`:`), a space, and the definition after the term. +To create a term list, precede each term with a dash (`-`) and a +space, the `term` keyword, and another space. Then add a colon (`:`), a space, and the definition after the term. ```markdown - term Ice: Ice sloths thrive below freezing temperatures. @@ -225,8 +225,8 @@ space, the `term` keyword, and another space. Then add a colon (`:`), a space, a - term Lightning: Lightning sloths thrive in stormy climates. ``` -A list item's text, including terms and their definitions, can use the same -style attributes as other text, and include links to other content, including +A list item's text, including terms and their definitions, can use the same +style attributes as other text, and include links to other content, including symbols. diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 3d7d0c44e..85c753bce 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, copyToClipboard: false)) + let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata, copyToClipboard: true)) 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 0f669cd1c..f44c3cf61 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, copyToClipboard: false)), + .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, copyToClipboard: true)), ])) ] @@ -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, 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: [.codeListing(.init(syntax: "swift", code: ["override func viewDidLoad() {", "super.viewDidLoad()", "}"], metadata: nil, copyToClipboard: true))], 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: true))], 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, 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: [.codeListing(.init(syntax: "swift", code: ["super.viewWillAppear()"], metadata: nil, copyToClipboard: true))], 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: true))], 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!"), ]) From 159ffce3589668001a3fde62d2f8c0e0e5a783cb Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 11 Aug 2025 15:30:50 -0600 Subject: [PATCH 19/39] add feature flag 'enable-experimental-code-block-annotations' for copy-to-clipboard and other code block annotations --- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 85c753bce..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, copyToClipboard: true)) + 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 f44c3cf61..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, copyToClipboard: true)), + .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, copyToClipboard: true))], 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: true))], 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, copyToClipboard: true))], 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: true))], 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!"), ]) From 33fa1362c7c4fa389d12a5b16141f0bd24927b21 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 8 Aug 2025 10:50:27 -0600 Subject: [PATCH 20/39] WIP wrap and highlight --- .../Content/RenderBlockContent.swift | 16 ++++++--- .../Rendering/RenderContentCompiler.swift | 36 +++++++++++++++---- .../Resources/RenderNode.spec.json | 9 +++++ 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 85ac03182..8062ec00e 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -125,6 +125,8 @@ public enum RenderBlockContent: Equatable { /// Additional metadata for this code block. public var metadata: RenderContentMetadata? public var copyToClipboard: Bool + public var wrap: Int = 100 + public var highlight: [Int] = [Int]() public enum OptionName: String, CaseIterable { case nocopy @@ -139,11 +141,13 @@ public enum RenderBlockContent: Equatable { } /// Make a new `CodeListing` with the given data. - public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled) { + public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, wrap: Int, highlight: [Int]) { self.syntax = syntax self.code = code self.metadata = metadata self.copyToClipboard = copyToClipboard + self.wrap = wrap + self.highlight = highlight } } @@ -711,7 +715,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, copyToClipboard + case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard, wrap, highlight case request, response case header, rows case numberOfColumns, columns @@ -738,8 +742,10 @@ extension RenderBlockContent: Codable { syntax: container.decodeIfPresent(String.self, forKey: .syntax), code: container.decode([String].self, forKey: .code), metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata), - copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy - )) + copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy, + wrap: container.decodeIfPresent(Int.self, forKey: .wrap) ?? 0, + highlight: container.decodeIfPresent([Int].self, forKey: .highlight) ?? [Int]() + )) 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: @@ -843,6 +849,8 @@ extension RenderBlockContent: Codable { try container.encode(l.code, forKey: .code) try container.encodeIfPresent(l.metadata, forKey: .metadata) try container.encode(l.copyToClipboard, forKey: .copyToClipboard) + try container.encode(l.wrap, forKey: .wrap) + try container.encode(l.highlight, forKey: .highlight) 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 e79e6fdfc..72573d8f7 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -59,10 +59,20 @@ struct RenderContentCompiler: MarkupVisitor { 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) + if let eq = part.firstIndex(of: "=") { + let name = part[.. Date: Wed, 13 Aug 2025 11:54:02 -0600 Subject: [PATCH 21/39] fix tests --- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +++++----- .../Utility/ListItemExtractorTests.swift | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 3d7d0c44e..b92469f33 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, copyToClipboard: false)) + let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata, copyToClipboard: false, wrap: 0, highlight: [])) 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 0f669cd1c..b156b8a57 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, copyToClipboard: false)), + .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [])), ])) ] @@ -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, 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: [.codeListing(.init(syntax: "swift", code: ["override func viewDidLoad() {", "super.viewDidLoad()", "}"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: []))], 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, wrap: 0, highlight: []))], 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, 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: [.codeListing(.init(syntax: "swift", code: ["super.viewWillAppear()"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: []))], 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, wrap: 0, highlight: []))], 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/Utility/ListItemExtractorTests.swift b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift index cdf61e5f3..db79af99a 100644 --- a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift +++ b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift @@ -514,7 +514,7 @@ class ListItemExtractorTests: XCTestCase { // ``` // Inner code block // ``` - .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: false)), + .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [])), // > Warning: Inner aside, with ``ThirdNotFoundSymbol`` link .aside(.init(style: .init(asideKind: .warning), content: [ From c9a92e6183e6dbf859df2f2d2cb2a48db2bbcd20 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 13 Aug 2025 17:25:50 -0600 Subject: [PATCH 22/39] WIP tests, WIP parsing for wrap and highlight --- .../Checkers/InvalidCodeBlockOption.swift | 1 + .../Content/RenderBlockContent.swift | 2 + .../Rendering/RenderContentCompiler.swift | 36 ++++-- .../InvalidCodeBlockOptionTests.swift | 2 +- .../RenderContentCompilerTests.swift | 120 +++++++++++++++++- 5 files changed, 149 insertions(+), 12 deletions(-) diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift index ca8bd2cb5..4b3c96640 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -34,6 +34,7 @@ internal struct InvalidCodeBlockOption: Checker { let info = codeBlock.language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !info.isEmpty else { return } + // TODO this will also fail on parsing highlight values with commas inside the array let tokens = info .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 8062ec00e..f4b2d5275 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -130,6 +130,8 @@ public enum RenderBlockContent: Equatable { public enum OptionName: String, CaseIterable { case nocopy + case wrap + case highlight init?(caseInsensitive raw: some StringProtocol) { self.init(rawValue: raw.lowercased()) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 72573d8f7..c5a4bd705 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -50,26 +50,27 @@ struct RenderContentCompiler: MarkupVisitor { if FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled { - func parseLanguageString(_ input: String?) -> (lang: String? , tokens: [RenderBlockContent.CodeListing.OptionName]) { + func parseLanguageString(_ input: String?) -> (lang: String? , tokens: [(RenderBlockContent.CodeListing.OptionName, Substring?)]) { guard let input else { return (lang: nil, tokens: []) } + // TODO this fails on parsing highlight values with commas inside the array let parts = input .split(separator: ",") .map { $0.trimmingCharacters(in: .whitespaces) } var lang: String? = nil - var options: [RenderBlockContent.CodeListing.OptionName] = [] + var options: [(RenderBlockContent.CodeListing.OptionName, Substring?)] = [] for part in parts { if let eq = part.firstIndex(of: "=") { let name = part[.. [Int]? { + guard var s = value.map(String.init) else { return nil } + s = s.trimmingCharacters(in: .whitespaces) + if s.hasPrefix("[") && s.hasSuffix("]") { + s.removeFirst() + s.removeLast() + } + let ints = s.split(separator: ",").compactMap{ Int($0.trimmingCharacters(in: .whitespaces)) } + return ints.isEmpty ? nil : ints + } + let options = parseLanguageString(codeBlock.language) let listing = RenderBlockContent.CodeListing( @@ -90,14 +102,18 @@ struct RenderContentCompiler: MarkupVisitor { ) // apply code block options - for option in options.tokens { + for (option, value) in options.tokens { switch option { case .nocopy: listing.copyToClipboard = false case .wrap: - listing.wrap = 0 //placeholder + if let value, let intValue = Int(value) { + listing.wrap = intValue + } else { + listing.wrap = 0 + } case .highlight: - listing.highlight = [Int]() //placeholder + listing.highlight = parseHighlight(value) ?? [] } } diff --git a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift index 2def720f1..9669bb04a 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift @@ -24,7 +24,7 @@ let a = 1 var checker = InvalidCodeBlockOption(sourceFile: nil) checker.visit(document) XCTAssertTrue(checker.problems.isEmpty) - XCTAssertEqual(RenderBlockContent.CodeListing.knownOptions, ["nocopy"]) + XCTAssertEqual(RenderBlockContent.CodeListing.knownOptions, ["highlight", "nocopy", "wrap"]) } func testOption() { diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index ef3604fd9..4d03150eb 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -282,7 +282,6 @@ class RenderContentCompilerTests: XCTestCase { ``` """# let document = Document(parsing: source) - let result = document.children.flatMap { compiler.visit($0) } let renderCodeBlock = try XCTUnwrap(result[0] as? RenderBlockContent) @@ -316,4 +315,123 @@ class RenderContentCompilerTests: XCTestCase { XCTAssertEqual(codeListing.syntax, "swift, nocopy") XCTAssertEqual(codeListing.copyToClipboard, false) } + + func testWrapAndHighlight() 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, wrap=20, highlight=[2] + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + + 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") + XCTAssertEqual(codeListing.wrap, 20) + XCTAssertEqual(codeListing.highlight, [2]) + } + + func testHighlight() 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, highlight=[2] + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + + 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") + XCTAssertEqual(codeListing.highlight, [2]) + } + + func testHighlightNoFeatureFlag() 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, highlight=[2] + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + + 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, highlight=[2]") + XCTAssertEqual(codeListing.highlight, []) + } + + func testMultipleHighlight() 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, highlight=[1, 2, 3] + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + + 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") + //XCTAssertEqual(codeListing.highlight, [1, 2, 3]) + } } From a0e47dd9759d8d256f9f37986630db496f6a916c Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Tue, 26 Aug 2025 18:30:19 -0600 Subject: [PATCH 23/39] change parsing to handle values after = and arrays --- .../Checkers/InvalidCodeBlockOption.swift | 28 +++--- .../Content/RenderBlockContent.swift | 1 + .../Rendering/RenderContentCompiler.swift | 51 ++--------- .../Utility/ParseLanguageString.swift | 90 +++++++++++++++++++ .../InvalidCodeBlockOptionTests.swift | 4 +- .../RenderContentCompilerTests.swift | 27 +++++- 6 files changed, 137 insertions(+), 64 deletions(-) create mode 100644 Sources/SwiftDocC/Utility/ParseLanguageString.swift diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift index 4b3c96640..14223ecd0 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -31,33 +31,29 @@ internal struct InvalidCodeBlockOption: Checker { } mutating func visitCodeBlock(_ codeBlock: CodeBlock) { - let info = codeBlock.language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !info.isEmpty else { return } + let (lang, tokens) = tokenizeLanguageString(codeBlock.language) - // TODO this will also fail on parsing highlight values with commas inside the array - let tokens = info - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - .filter { !$0.isEmpty } + func matches(token: RenderBlockContent.CodeListing.OptionName, value: String?) { + guard token == .unknown, let value = value else { return } - 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) + let matches = NearMiss.bestMatches(for: knownOptions, against: value) 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 diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(value.singleQuoted) in code block.") let possibleSolutions = matches.map { candidate in Solution( - summary: "Replace \(token.singleQuoted) with \(candidate.singleQuoted).", + summary: "Replace \(value.singleQuoted) with \(candidate.singleQuoted).", replacements: [] ) } problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions)) } } + + for (token, value) in tokens { + matches(token: token, value: value) + } + // check if first token (lang) might be a typo + matches(token: .unknown, value: lang) } } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index f4b2d5275..015e21dff 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -132,6 +132,7 @@ public enum RenderBlockContent: Equatable { case nocopy case wrap case highlight + case unknown init?(caseInsensitive raw: some StringProtocol) { self.init(rawValue: raw.lowercased()) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index c5a4bd705..444709625 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -49,51 +49,10 @@ struct RenderContentCompiler: MarkupVisitor { // Default to the bundle's code listing syntax if one is not explicitly declared in the code block. if FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled { + let (lang, tokens) = tokenizeLanguageString(codeBlock.language) - func parseLanguageString(_ input: String?) -> (lang: String? , tokens: [(RenderBlockContent.CodeListing.OptionName, Substring?)]) { - guard let input else { return (lang: nil, tokens: []) } - // TODO this fails on parsing highlight values with commas inside the array - let parts = input - .split(separator: ",") - .map { $0.trimmingCharacters(in: .whitespaces) } - var lang: String? = nil - var options: [(RenderBlockContent.CodeListing.OptionName, Substring?)] = [] - - for part in parts { - if let eq = part.firstIndex(of: "=") { - let name = part[.. [Int]? { - guard var s = value.map(String.init) else { return nil } - s = s.trimmingCharacters(in: .whitespaces) - if s.hasPrefix("[") && s.hasSuffix("]") { - s.removeFirst() - s.removeLast() - } - let ints = s.split(separator: ",").compactMap{ Int($0.trimmingCharacters(in: .whitespaces)) } - return ints.isEmpty ? nil : ints - } - - let options = parseLanguageString(codeBlock.language) - - let listing = RenderBlockContent.CodeListing( - syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, + var listing = RenderBlockContent.CodeListing( + syntax: lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: !options.tokens.contains(.nocopy), @@ -102,7 +61,7 @@ struct RenderContentCompiler: MarkupVisitor { ) // apply code block options - for (option, value) in options.tokens { + for (option, value) in tokens { switch option { case .nocopy: listing.copyToClipboard = false @@ -114,6 +73,8 @@ struct RenderContentCompiler: MarkupVisitor { } case .highlight: listing.highlight = parseHighlight(value) ?? [] + case .unknown: + break } } diff --git a/Sources/SwiftDocC/Utility/ParseLanguageString.swift b/Sources/SwiftDocC/Utility/ParseLanguageString.swift new file mode 100644 index 000000000..a0ba01ef6 --- /dev/null +++ b/Sources/SwiftDocC/Utility/ParseLanguageString.swift @@ -0,0 +1,90 @@ +/* + 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 +*/ + +public func parseHighlight(_ value: String?) -> [Int]? { + guard var s = value?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return [] } + + if s.hasPrefix("[") && s.hasSuffix("]") { + s.removeFirst() + s.removeLast() + } + + return s.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } +} + +/// A function that parses the language line options on code blocks, returning the language and tokens, an array of OptionName and option values +public func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: [(RenderBlockContent.CodeListing.OptionName, String?)]) { + guard let input else { return (lang: nil, tokens: []) } + + let parts = parseLanguageString(input) + var tokens: [(RenderBlockContent.CodeListing.OptionName, String?)] = [] + var lang: String? = nil + + for (index, part) in parts.enumerated() { + if let eq = part.firstIndex(of: "=") { + let key = part[.. [Substring] { + + guard let input else { return [] } + var parts: [Substring] = [] + var start = input.startIndex + var i = input.startIndex + + var bracketDepth = 0 + + while i < input.endIndex { + let c = input[i] + + if c == "[" { bracketDepth += 1 } + else if c == "]" { bracketDepth = max(0, bracketDepth - 1) } + else if c == "," && bracketDepth == 0 { + let seq = input[start.. Date: Thu, 28 Aug 2025 13:57:32 -0600 Subject: [PATCH 24/39] add strikeout option --- .../Model/Rendering/Content/RenderBlockContent.swift | 11 ++++++++--- .../Model/Rendering/RenderContentCompiler.swift | 11 +++++++---- .../SwiftDocC.docc/Resources/RenderNode.spec.json | 6 ++++++ Sources/SwiftDocC/Utility/ParseLanguageString.swift | 5 +++-- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +++++----- .../Utility/ListItemExtractorTests.swift | 2 +- 7 files changed, 31 insertions(+), 16 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 015e21dff..c0c1b6b3c 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -127,11 +127,13 @@ public enum RenderBlockContent: Equatable { public var copyToClipboard: Bool public var wrap: Int = 100 public var highlight: [Int] = [Int]() + public var strikeout: [Int] = [Int]() public enum OptionName: String, CaseIterable { case nocopy case wrap case highlight + case strikeout case unknown init?(caseInsensitive raw: some StringProtocol) { @@ -144,13 +146,14 @@ public enum RenderBlockContent: Equatable { } /// Make a new `CodeListing` with the given data. - public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, wrap: Int, highlight: [Int]) { + public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, wrap: Int, highlight: [Int], strikeout: [Int]) { self.syntax = syntax self.code = code self.metadata = metadata self.copyToClipboard = copyToClipboard self.wrap = wrap self.highlight = highlight + self.strikeout = strikeout } } @@ -718,7 +721,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, copyToClipboard, wrap, highlight + case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard, wrap, highlight, strikeout case request, response case header, rows case numberOfColumns, columns @@ -747,7 +750,8 @@ extension RenderBlockContent: Codable { metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata), copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy, wrap: container.decodeIfPresent(Int.self, forKey: .wrap) ?? 0, - highlight: container.decodeIfPresent([Int].self, forKey: .highlight) ?? [Int]() + highlight: container.decodeIfPresent([Int].self, forKey: .highlight) ?? [Int](), + strikeout: container.decodeIfPresent([Int].self, forKey: .strikeout) ?? [Int]() )) 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))) @@ -854,6 +858,7 @@ extension RenderBlockContent: Codable { try container.encode(l.copyToClipboard, forKey: .copyToClipboard) try container.encode(l.wrap, forKey: .wrap) try container.encode(l.highlight, forKey: .highlight) + try container.encode(l.strikeout, forKey: .strikeout) 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 444709625..4cd130eab 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -56,8 +56,9 @@ struct RenderContentCompiler: MarkupVisitor { code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: !options.tokens.contains(.nocopy), - wrap: 0, - highlight: [Int]() + wrap: 0, // default value + highlight: [Int](), // default value + strikeout: [Int]() // default value ) // apply code block options @@ -72,7 +73,9 @@ struct RenderContentCompiler: MarkupVisitor { listing.wrap = 0 } case .highlight: - listing.highlight = parseHighlight(value) ?? [] + listing.highlight = parseCodeBlockOptionArray(value) ?? [] + case .strikeout: + listing.strikeout = parseCodeBlockOptionArray(value) ?? [] case .unknown: break } @@ -81,7 +84,7 @@ struct RenderContentCompiler: MarkupVisitor { return [RenderBlockContent.codeListing(listing)] } else { - return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: false, wrap: 0, highlight: [Int]()))] + return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: false, wrap: 0, highlight: [Int](), strikeout: [Int]()))] } } diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index b26ece619..a7857aba7 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -817,6 +817,12 @@ "items": { "type": "integer" } + }, + "strikeout": { + "type": "array", + "items": { + "type": "integer" + } } } }, diff --git a/Sources/SwiftDocC/Utility/ParseLanguageString.swift b/Sources/SwiftDocC/Utility/ParseLanguageString.swift index a0ba01ef6..1882323e8 100644 --- a/Sources/SwiftDocC/Utility/ParseLanguageString.swift +++ b/Sources/SwiftDocC/Utility/ParseLanguageString.swift @@ -7,8 +7,8 @@ See https://swift.org/LICENSE.txt for license information See https://swift.org/CONTRIBUTORS.txt for Swift project authors */ - -public func parseHighlight(_ value: String?) -> [Int]? { +/// A function that parses array values on code block options from the language line string +public func parseCodeBlockOptionArray(_ value: String?) -> [Int]? { guard var s = value?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return [] } if s.hasPrefix("[") && s.hasSuffix("]") { @@ -56,6 +56,7 @@ public func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: return (lang, tokens) } +// helper function for tokenizeLanguageString to parse the language line func parseLanguageString(_ input: String?) -> [Substring] { guard let input else { return [] } diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index b92469f33..7ae341db4 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, copyToClipboard: false, wrap: 0, highlight: [])) + let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [])) 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 b156b8a57..09791eb81 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, copyToClipboard: false, wrap: 0, highlight: [])), + .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [])), ])) ] @@ -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, copyToClipboard: false, wrap: 0, highlight: []))], 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, wrap: 0, highlight: []))], 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, wrap: 0, highlight: [], strikeout: []))], 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, wrap: 0, highlight: [], strikeout: []))], 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, copyToClipboard: false, wrap: 0, highlight: []))], 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, wrap: 0, highlight: []))], 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, wrap: 0, highlight: [], strikeout: []))], 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, wrap: 0, highlight: [], strikeout: []))], 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/Utility/ListItemExtractorTests.swift b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift index db79af99a..c73b05578 100644 --- a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift +++ b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift @@ -514,7 +514,7 @@ class ListItemExtractorTests: XCTestCase { // ``` // Inner code block // ``` - .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [])), + .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [])), // > Warning: Inner aside, with ``ThirdNotFoundSymbol`` link .aside(.init(style: .init(asideKind: .warning), content: [ From d787165e586668289ec288da03bb169c8aaee461 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 29 Aug 2025 15:45:09 -0600 Subject: [PATCH 25/39] parse strikeout option, solution for language not as the first option on language line, tests --- .../Checkers/InvalidCodeBlockOption.swift | 8 ++ .../Utility/ParseLanguageString.swift | 4 + .../InvalidCodeBlockOptionTests.swift | 20 +++- .../RenderContentCompilerTests.swift | 94 +++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift index 14223ecd0..f8a0367db 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -47,6 +47,14 @@ internal struct InvalidCodeBlockOption: Checker { ) } problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions)) + } else if lang == nil { + let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(value.singleQuoted) in code block.") + let possibleSolutions = + Solution( + summary: "If \(value.singleQuoted) is the language for this code block, then write \(value.singleQuoted) as the first option.", + replacements: [] + ) + problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [possibleSolutions])) } } diff --git a/Sources/SwiftDocC/Utility/ParseLanguageString.swift b/Sources/SwiftDocC/Utility/ParseLanguageString.swift index 1882323e8..bba60fd44 100644 --- a/Sources/SwiftDocC/Utility/ParseLanguageString.swift +++ b/Sources/SwiftDocC/Utility/ParseLanguageString.swift @@ -35,6 +35,8 @@ public func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: tokens.append((.wrap, value)) } else if key == "highlight" { tokens.append((.highlight, value)) + } else if key == "strikeout" { + tokens.append((.strikeout, value)) } else { tokens.append((.unknown, key)) } @@ -46,6 +48,8 @@ public func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: tokens.append((.wrap, nil as String?)) } else if key == "highlight" { tokens.append((.highlight, nil as String?)) + } else if key == "strikeout" { + tokens.append((.strikeout, nil as String?)) } else if index == 0 && !key.contains("[") && !key.contains("]") { lang = key } else { diff --git a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift index 68cb10a44..bda245d8e 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift @@ -24,7 +24,7 @@ let a = 1 var checker = InvalidCodeBlockOption(sourceFile: nil) checker.visit(document) XCTAssertTrue(checker.problems.isEmpty) - XCTAssertEqual(RenderBlockContent.CodeListing.knownOptions, ["highlight", "nocopy", "unknown", "wrap"]) + XCTAssertEqual(RenderBlockContent.CodeListing.knownOptions, ["highlight", "nocopy", "strikeout", "unknown", "wrap"]) } func testOption() { @@ -104,5 +104,23 @@ let g = 7 } } + + func testLanguageNotFirst() { + let markupSource = """ +```nocopy, swift, highlight=[1] +let b = 2 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + XCTAssertEqual(1, checker.problems.count) + + for problem in checker.problems { + XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", problem.diagnostic.identifier) + XCTAssertEqual(problem.diagnostic.summary, "Unknown option 'swift' in code block.") + XCTAssertEqual(problem.possibleSolutions.map(\.summary), ["If 'swift' is the language for this code block, then write 'swift' as the first option."]) + } + } } diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index 282dfa770..693347cfe 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -459,4 +459,98 @@ class RenderContentCompilerTests: XCTestCase { XCTAssertEqual(codeListing.syntax, "swift") XCTAssertEqual(codeListing.highlight, [1, 2, 3]) } + + func testMultipleHighlightMultipleStrikeout() 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, strikeout=[3,5], highlight=[1, 2, 3] + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + + 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") + XCTAssertEqual(codeListing.highlight, [1, 2, 3]) + XCTAssertEqual(codeListing.strikeout, [3, 5]) + } + + func testLanguageNotFirstOption() 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 = #""" + ```highlight=[1, 2, 3], swift, wrap=20, strikeout=[3] + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + + 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.highlight, [1, 2, 3]) + // we expect the language to be the first option in the language line, otherwise it remains nil. + XCTAssertEqual(codeListing.syntax, nil) + XCTAssertEqual(codeListing.wrap, 20) + XCTAssertEqual(codeListing.strikeout, [3]) + } + + func testUnorderedArrayOptions() 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 = #""" + ```highlight=[5,3,4], strikeout=[3,1] + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + + 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.highlight, [5, 3, 4]) + XCTAssertEqual(codeListing.strikeout, [3, 1]) + } } From 7ea301db5d113c95d425dd5b2f1891a9c4e12c6e Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 29 Aug 2025 18:13:42 -0600 Subject: [PATCH 26/39] validate array values in code block options for highlight and strikeout --- .../Checkers/InvalidCodeBlockOption.swift | 28 +++++++++++++++ .../InvalidCodeBlockOptionTests.swift | 36 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift index f8a0367db..1532178f9 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -58,8 +58,36 @@ internal struct InvalidCodeBlockOption: Checker { } } + func validateArrayIndices(token: RenderBlockContent.CodeListing.OptionName, value: String?) { + guard token == .highlight || token == .strikeout, let value = value else { return } + // code property ends in a newline. this gives us a bogus extra line. + let lineCount: Int = codeBlock.code.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }).count - 1 + + guard let indices = parseCodeBlockOptionArray(value) else { + let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Could not parse \(token.rawValue.singleQuoted) indices from \(value.singleQuoted). Expected an integer (e.g. 3) or an array (e.g. [1, 3, 5])") + problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) + return + } + + let invalid = indices.filter { $0 < 1 || $0 > lineCount } + guard !invalid.isEmpty else { return } + + let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Invalid \(token.rawValue.singleQuoted) index\(invalid.count == 1 ? "" : "es") in \(value.singleQuoted) for a code block with \(lineCount) line\(lineCount == 1 ? "" : "s"). Valid range is 1...\(lineCount).") + let solutions: [Solution] = { + if invalid.contains(where: {$0 == lineCount + 1}) { + return [Solution( + summary: "If you intended the last line, change '\(lineCount + 1)' to \(lineCount).", + replacements: [] + )] + } + return [] + }() + problems.append(Problem(diagnostic: diagnostic, possibleSolutions: solutions)) + } + for (token, value) in tokens { matches(token: token, value: value) + validateArrayIndices(token: token, value: value) } // check if first token (lang) might be a typo matches(token: .unknown, value: lang) diff --git a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift index bda245d8e..9adf48b02 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift @@ -122,5 +122,41 @@ let b = 2 XCTAssertEqual(problem.possibleSolutions.map(\.summary), ["If 'swift' is the language for this code block, then write 'swift' as the first option."]) } } + + func testInvalidHighlightIndex() throws { + let markupSource = """ +```swift, nocopy, highlight=[2] +let b = 2 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + XCTAssertEqual(1, checker.problems.count) + let problem = try XCTUnwrap(checker.problems.first) + + XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", problem.diagnostic.identifier) + XCTAssertEqual(problem.diagnostic.summary, "Invalid 'highlight' index in '[2]' for a code block with 1 line. Valid range is 1...1.") + XCTAssertEqual(problem.possibleSolutions.map(\.summary), ["If you intended the last line, change '2' to 1."]) + } + + func testInvalidHighlightandStrikeoutIndex() throws { + let markupSource = """ +```swift, nocopy, highlight=[0], strikeout=[-1, 4] +let a = 1 +let b = 2 +let c = 3 +``` +""" + let document = Document(parsing: markupSource, options: []) + var checker = InvalidCodeBlockOption(sourceFile: URL(fileURLWithPath: #file)) + checker.visit(document) + XCTAssertEqual(2, checker.problems.count) + + XCTAssertEqual("org.swift.docc.InvalidCodeBlockOption", checker.problems[0].diagnostic.identifier) + XCTAssertEqual(checker.problems[0].diagnostic.summary, "Invalid 'highlight' index in '[0]' for a code block with 3 lines. Valid range is 1...3.") + XCTAssertEqual(checker.problems[1].diagnostic.summary, "Invalid 'strikeout' indexes in '[-1, 4]' for a code block with 3 lines. Valid range is 1...3.") + XCTAssertEqual(checker.problems[1].possibleSolutions.map(\.summary), ["If you intended the last line, change '4' to 3."]) + } } From 543e61dd8a2e1cb60c4c216cfa2b00d11b5e7d0f Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 5 Sep 2025 09:26:58 -0600 Subject: [PATCH 27/39] showLineNumbers option --- .../Model/Rendering/Content/RenderBlockContent.swift | 11 ++++++++--- .../Model/Rendering/RenderContentCompiler.swift | 7 +++++-- .../SwiftDocC.docc/Resources/RenderNode.spec.json | 3 +++ Sources/SwiftDocC/Utility/ParseLanguageString.swift | 2 ++ .../Checkers/InvalidCodeBlockOptionTests.swift | 2 +- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +++++----- .../Utility/ListItemExtractorTests.swift | 2 +- 8 files changed, 26 insertions(+), 13 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index c0c1b6b3c..3145842aa 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -127,12 +127,14 @@ public enum RenderBlockContent: Equatable { public var copyToClipboard: Bool public var wrap: Int = 100 public var highlight: [Int] = [Int]() + public var showLineNumbers: Bool public var strikeout: [Int] = [Int]() public enum OptionName: String, CaseIterable { case nocopy case wrap case highlight + case showLineNumbers case strikeout case unknown @@ -146,13 +148,14 @@ public enum RenderBlockContent: Equatable { } /// Make a new `CodeListing` with the given data. - public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, wrap: Int, highlight: [Int], strikeout: [Int]) { + public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, wrap: Int, highlight: [Int], strikeout: [Int], showLineNumbers: Bool = false) { self.syntax = syntax self.code = code self.metadata = metadata self.copyToClipboard = copyToClipboard self.wrap = wrap self.highlight = highlight + self.showLineNumbers = showLineNumbers self.strikeout = strikeout } } @@ -721,7 +724,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, copyToClipboard, wrap, highlight, strikeout + case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard, wrap, highlight, strikeout, showLineNumbers case request, response case header, rows case numberOfColumns, columns @@ -751,7 +754,8 @@ extension RenderBlockContent: Codable { copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy, wrap: container.decodeIfPresent(Int.self, forKey: .wrap) ?? 0, highlight: container.decodeIfPresent([Int].self, forKey: .highlight) ?? [Int](), - strikeout: container.decodeIfPresent([Int].self, forKey: .strikeout) ?? [Int]() + strikeout: container.decodeIfPresent([Int].self, forKey: .strikeout) ?? [Int](), + showLineNumbers: container.decodeIfPresent(Bool.self, forKey: .showLineNumbers) ?? 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))) @@ -859,6 +863,7 @@ extension RenderBlockContent: Codable { try container.encode(l.wrap, forKey: .wrap) try container.encode(l.highlight, forKey: .highlight) try container.encode(l.strikeout, forKey: .strikeout) + try container.encode(l.showLineNumbers, forKey: .showLineNumbers) 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 4cd130eab..633625d51 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -58,7 +58,8 @@ struct RenderContentCompiler: MarkupVisitor { copyToClipboard: !options.tokens.contains(.nocopy), wrap: 0, // default value highlight: [Int](), // default value - strikeout: [Int]() // default value + strikeout: [Int](), // default value + showLineNumbers: false, // default value ) // apply code block options @@ -76,6 +77,8 @@ struct RenderContentCompiler: MarkupVisitor { listing.highlight = parseCodeBlockOptionArray(value) ?? [] case .strikeout: listing.strikeout = parseCodeBlockOptionArray(value) ?? [] + case .showLineNumbers: + listing.showLineNumbers = true case .unknown: break } @@ -84,7 +87,7 @@ struct RenderContentCompiler: MarkupVisitor { return [RenderBlockContent.codeListing(listing)] } else { - return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: false, wrap: 0, highlight: [Int](), strikeout: [Int]()))] + return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: false, wrap: 0, highlight: [Int](), strikeout: [Int](), showLineNumbers: false))] } } diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index a7857aba7..0c8e3e5a0 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -809,6 +809,9 @@ "copyToClipboard": { "type": "boolean" }, + "showLineNumbers": { + "type": "boolean" + }, "wrap": { "type": "integer" }, diff --git a/Sources/SwiftDocC/Utility/ParseLanguageString.swift b/Sources/SwiftDocC/Utility/ParseLanguageString.swift index bba60fd44..d88b69815 100644 --- a/Sources/SwiftDocC/Utility/ParseLanguageString.swift +++ b/Sources/SwiftDocC/Utility/ParseLanguageString.swift @@ -44,6 +44,8 @@ public func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: let key = part.trimmingCharacters(in: .whitespaces).lowercased() if key == "nocopy" { tokens.append((.nocopy, nil as String?)) + } else if key == "showlinenumbers" { + tokens.append((.showLineNumbers, nil as String?)) } else if key == "wrap" { tokens.append((.wrap, nil as String?)) } else if key == "highlight" { diff --git a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift index 9adf48b02..7cd4cf89b 100644 --- a/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift +++ b/Tests/SwiftDocCTests/Checker/Checkers/InvalidCodeBlockOptionTests.swift @@ -24,7 +24,7 @@ let a = 1 var checker = InvalidCodeBlockOption(sourceFile: nil) checker.visit(document) XCTAssertTrue(checker.problems.isEmpty) - XCTAssertEqual(RenderBlockContent.CodeListing.knownOptions, ["highlight", "nocopy", "strikeout", "unknown", "wrap"]) + XCTAssertEqual(RenderBlockContent.CodeListing.knownOptions, ["highlight", "nocopy", "strikeout", "unknown", "wrap", "showLineNumbers"]) } func testOption() { diff --git a/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift b/Tests/SwiftDocCTests/Model/RenderContentMetadataTests.swift index 7ae341db4..02f74b088 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, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [])) + let code = RenderBlockContent.codeListing(.init(syntax: nil, code: [], metadata: metadata, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [], showLineNumbers: 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 09791eb81..d09d465f8 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, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [])), + .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [], showLineNumbers: 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, copyToClipboard: false, wrap: 0, highlight: [], strikeout: []))], 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, wrap: 0, highlight: [], strikeout: []))], 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, wrap: 0, highlight: [], strikeout: [], showLineNumbers: 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, wrap: 0, highlight: [], strikeout: [], showLineNumbers: 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, copyToClipboard: false, wrap: 0, highlight: [], strikeout: []))], 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, wrap: 0, highlight: [], strikeout: []))], 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, wrap: 0, highlight: [], strikeout: [], showLineNumbers: 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, wrap: 0, highlight: [], strikeout: [], showLineNumbers: 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/Utility/ListItemExtractorTests.swift b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift index c73b05578..21389ac41 100644 --- a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift +++ b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift @@ -514,7 +514,7 @@ class ListItemExtractorTests: XCTestCase { // ``` // Inner code block // ``` - .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [])), + .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [], showLineNumbers: false)), // > Warning: Inner aside, with ``ThirdNotFoundSymbol`` link .aside(.init(style: .init(asideKind: .warning), content: [ From 00f4fc5cfb9fa338f3a4367888ed2fe3bf0ed712 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 5 Sep 2025 17:02:09 -0600 Subject: [PATCH 28/39] remove trailing comma --- Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 633625d51..699201057 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -59,7 +59,7 @@ struct RenderContentCompiler: MarkupVisitor { wrap: 0, // default value highlight: [Int](), // default value strikeout: [Int](), // default value - showLineNumbers: false, // default value + showLineNumbers: false // default value ) // apply code block options From d1243d471d8d0c52d6fe7e8076e2939a538b83bf Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 8 Sep 2025 16:26:03 -0600 Subject: [PATCH 29/39] test showLineNumbers --- .../RenderContentCompilerTests.swift | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index 693347cfe..a758b296b 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -341,6 +341,62 @@ class RenderContentCompilerTests: XCTestCase { XCTAssertEqual(codeListing.copyToClipboard, false) } + func testShowLineNumbers() 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, showLineNumbers + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + 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.showLineNumbers, true) + } + + func testLowercaseShowLineNumbers() 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, showlinenumbers + let a = 1 + let b = 2 + let c = 3 + let d = 4 + let e = 5 + ``` + """# + 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.showLineNumbers, true) + } + func testWrapAndHighlight() async throws { enableFeatureFlag(\.isExperimentalCodeBlockAnnotationsEnabled) @@ -498,7 +554,7 @@ class RenderContentCompilerTests: XCTestCase { var compiler = RenderContentCompiler(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleID: bundle.id, path: "/path", fragment: nil, sourceLanguage: .swift)) let source = #""" - ```highlight=[1, 2, 3], swift, wrap=20, strikeout=[3] + ```showLineNumbers, highlight=[1, 2, 3], swift, wrap=20, strikeout=[3] let a = 1 let b = 2 let c = 3 @@ -517,6 +573,7 @@ class RenderContentCompilerTests: XCTestCase { return } + XCTAssertEqual(codeListing.showLineNumbers, true) XCTAssertEqual(codeListing.highlight, [1, 2, 3]) // we expect the language to be the first option in the language line, otherwise it remains nil. XCTAssertEqual(codeListing.syntax, nil) From 65e5404c17cfb5aefce6e1c7fd33772a2e1417b1 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Thu, 18 Sep 2025 20:30:58 -0600 Subject: [PATCH 30/39] PR feedback --- .../Checkers/InvalidCodeBlockOption.swift | 10 +- .../Content/RenderBlockContent.swift | 183 ++++++++++++++++-- .../Rendering/RenderContentCompiler.swift | 37 +--- .../Utility/ParseLanguageString.swift | 97 ---------- .../InvalidCodeBlockOptionTests.swift | 1 - .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +- .../RenderContentCompilerTests.swift | 40 ++-- .../Utility/ListItemExtractorTests.swift | 2 +- 9 files changed, 202 insertions(+), 180 deletions(-) delete mode 100644 Sources/SwiftDocC/Utility/ParseLanguageString.swift diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift index 1532178f9..6d08cedf7 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -19,7 +19,7 @@ internal struct InvalidCodeBlockOption: Checker { var problems = [Problem]() /// Parsing options for code blocks - private let knownOptions = RenderBlockContent.CodeListing.knownOptions + private let knownOptions = RenderBlockContent.CodeBlockOptions.knownOptions private var sourceFile: URL? @@ -31,9 +31,9 @@ internal struct InvalidCodeBlockOption: Checker { } mutating func visitCodeBlock(_ codeBlock: CodeBlock) { - let (lang, tokens) = tokenizeLanguageString(codeBlock.language) + let (lang, tokens) = RenderBlockContent.CodeBlockOptions.tokenizeLanguageString(codeBlock.language) - func matches(token: RenderBlockContent.CodeListing.OptionName, value: String?) { + func matches(token: RenderBlockContent.CodeBlockOptions.OptionName, value: String?) { guard token == .unknown, let value = value else { return } let matches = NearMiss.bestMatches(for: knownOptions, against: value) @@ -58,12 +58,12 @@ internal struct InvalidCodeBlockOption: Checker { } } - func validateArrayIndices(token: RenderBlockContent.CodeListing.OptionName, value: String?) { + func validateArrayIndices(token: RenderBlockContent.CodeBlockOptions.OptionName, value: String?) { guard token == .highlight || token == .strikeout, let value = value else { return } // code property ends in a newline. this gives us a bogus extra line. let lineCount: Int = codeBlock.code.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }).count - 1 - guard let indices = parseCodeBlockOptionArray(value) else { + guard let indices = RenderBlockContent.CodeBlockOptions.parseCodeBlockOptionsArray(value) else { let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Could not parse \(token.rawValue.singleQuoted) indices from \(value.singleQuoted). Expected an integer (e.g. 3) or an array (e.g. [1, 3, 5])") problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) return diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 3145842aa..f0fc0fcf6 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -124,13 +124,28 @@ public enum RenderBlockContent: Equatable { public var code: [String] /// Additional metadata for this code block. public var metadata: RenderContentMetadata? + /// Annotations for code blocks + public var options: CodeBlockOptions? + + /// Make a new `CodeListing` with the given data. + public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, options: CodeBlockOptions?) { + self.syntax = syntax + self.code = code + self.metadata = metadata + self.options = options + } + } + + public struct CodeBlockOptions: Equatable { + public var language: String? public var copyToClipboard: Bool - public var wrap: Int = 100 - public var highlight: [Int] = [Int]() + public var wrap: Int + public var highlight: [Int] public var showLineNumbers: Bool - public var strikeout: [Int] = [Int]() + public var strikeout: [Int] public enum OptionName: String, CaseIterable { + case _nonFrozenEnum_useDefaultCase case nocopy case wrap case highlight @@ -147,17 +162,141 @@ public enum RenderBlockContent: Equatable { Set(OptionName.allCases.map(\.rawValue)) } - /// Make a new `CodeListing` with the given data. - public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, wrap: Int, highlight: [Int], strikeout: [Int], showLineNumbers: Bool = false) { - self.syntax = syntax - self.code = code - self.metadata = metadata + // empty initializer with default values + public init() { + self.language = "" + self.copyToClipboard = true + self.wrap = 0 + self.highlight = [] + self.showLineNumbers = false + self.strikeout = [] + } + + public init(parsingLanguageString language: String?) { + let (lang, tokens) = Self.tokenizeLanguageString(language) + + self.language = lang + self.copyToClipboard = !tokens.contains { $0.name == .nocopy } + self.showLineNumbers = tokens.contains { $0.name == .showLineNumbers } + + if let wrapString = tokens.first(where: { $0.name == .wrap })?.value, + let wrapValue = Int(wrapString) { + self.wrap = wrapValue + } else { + self.wrap = 0 + } + + if let highlightString = tokens.first(where: { $0.name == .highlight })?.value, + let highlightValue = Self.parseCodeBlockOptionsArray(highlightString) { + self.highlight = highlightValue + } else { + self.highlight = [] + } + + if let strikeoutString = tokens.first(where: { $0.name == .strikeout })?.value, + let strikeoutValue = Self.parseCodeBlockOptionsArray(strikeoutString) { + self.strikeout = strikeoutValue + } else { + self.strikeout = [] + } + } + + public init(copyToClipboard: Bool, wrap: Int, highlight: [Int], strikeout: [Int], showLineNumbers: Bool) { self.copyToClipboard = copyToClipboard self.wrap = wrap self.highlight = highlight self.showLineNumbers = showLineNumbers self.strikeout = strikeout } + + /// A function that parses array values on code block options from the language line string + static internal func parseCodeBlockOptionsArray(_ value: String?) -> [Int]? { + guard var s = value?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return [] } + + if s.hasPrefix("[") && s.hasSuffix("]") { + s.removeFirst() + s.removeLast() + } + + return s.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } + } + + /// A function that parses the language line options on code blocks, returning the language and tokens, an array of OptionName and option values + static internal func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: [(name: OptionName, value: String?)]) { + guard let input else { return (lang: nil, tokens: []) } + + let parts = parseLanguageString(input) + var tokens: [(OptionName, String?)] = [] + var lang: String? = nil + + for (index, part) in parts.enumerated() { + if let eq = part.firstIndex(of: "=") { + let key = part[.. [Substring] { + + guard let input else { return [] } + var parts: [Substring] = [] + var start = input.startIndex + var i = input.startIndex + + var bracketDepth = 0 + + while i < input.endIndex { + let c = input[i] + + if c == "[" { bracketDepth += 1 } + else if c == "]" { bracketDepth = max(0, bracketDepth - 1) } + else if c == "," && bracketDepth == 0 { + let seq = input[start.. [Int]? { - guard var s = value?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return [] } - - if s.hasPrefix("[") && s.hasSuffix("]") { - s.removeFirst() - s.removeLast() - } - - return s.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) } -} - -/// A function that parses the language line options on code blocks, returning the language and tokens, an array of OptionName and option values -public func tokenizeLanguageString(_ input: String?) -> (lang: String?, tokens: [(RenderBlockContent.CodeListing.OptionName, String?)]) { - guard let input else { return (lang: nil, tokens: []) } - - let parts = parseLanguageString(input) - var tokens: [(RenderBlockContent.CodeListing.OptionName, String?)] = [] - var lang: String? = nil - - for (index, part) in parts.enumerated() { - if let eq = part.firstIndex(of: "=") { - let key = part[.. [Substring] { - - guard let input else { return [] } - var parts: [Substring] = [] - var start = input.startIndex - var i = input.startIndex - - var bracketDepth = 0 - - while i < input.endIndex { - let c = input[i] - - if c == "[" { bracketDepth += 1 } - else if c == "]" { bracketDepth = max(0, bracketDepth - 1) } - else if c == "," && bracketDepth == 0 { - let seq = input[start.. Run")]), .text(" menu item, or the following code:"), ])), - .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [], showLineNumbers: false)), + .codeListing(.init(syntax: "swift", code: ["xcrun xcodebuild -h", "xcrun xcodebuild build -configuration Debug"], metadata: nil, options: nil)), ])) ] @@ -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, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [], showLineNumbers: 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, wrap: 0, highlight: [], strikeout: [], showLineNumbers: false))], 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, options: 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, options: nil))], 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, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [], showLineNumbers: 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, wrap: 0, highlight: [], strikeout: [], showLineNumbers: false))], isCorrect: true, justification: [.paragraph(.init(inlineContent: [.text("It's correct because...")]))], reaction: "Yep."), + .init(content: [.codeListing(.init(syntax: "swift", code: ["super.viewWillAppear()"], metadata: nil, options: 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, options: nil))], 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 a758b296b..ef275cbfd 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -245,7 +245,7 @@ class RenderContentCompilerTests: XCTestCase { return } - XCTAssertEqual(codeListing.copyToClipboard, true) + XCTAssertEqual(codeListing.options?.copyToClipboard, true) } func testNoCopyToClipboard() async throws { @@ -269,7 +269,7 @@ class RenderContentCompilerTests: XCTestCase { return } - XCTAssertEqual(codeListing.copyToClipboard, false) + XCTAssertEqual(codeListing.options?.copyToClipboard, false) } func testCopyToClipboardNoLang() async throws { @@ -294,7 +294,7 @@ class RenderContentCompilerTests: XCTestCase { } XCTAssertEqual(codeListing.syntax, nil) - XCTAssertEqual(codeListing.copyToClipboard, false) + XCTAssertEqual(codeListing.options?.copyToClipboard, false) } func testCopyToClipboardNoFeatureFlag() async throws { @@ -315,7 +315,7 @@ class RenderContentCompilerTests: XCTestCase { return } - XCTAssertEqual(codeListing.copyToClipboard, false) + XCTAssertEqual(codeListing.options?.copyToClipboard, nil) } func testNoCopyToClipboardNoFeatureFlag() async throws { @@ -338,7 +338,7 @@ class RenderContentCompilerTests: XCTestCase { } XCTAssertEqual(codeListing.syntax, "swift, nocopy") - XCTAssertEqual(codeListing.copyToClipboard, false) + XCTAssertEqual(codeListing.options?.copyToClipboard, nil) } func testShowLineNumbers() async throws { @@ -366,7 +366,7 @@ class RenderContentCompilerTests: XCTestCase { return } - XCTAssertEqual(codeListing.showLineNumbers, true) + XCTAssertEqual(codeListing.options?.showLineNumbers, true) } func testLowercaseShowLineNumbers() async throws { @@ -394,7 +394,7 @@ class RenderContentCompilerTests: XCTestCase { return } - XCTAssertEqual(codeListing.showLineNumbers, true) + XCTAssertEqual(codeListing.options?.showLineNumbers, true) } func testWrapAndHighlight() async throws { @@ -424,8 +424,8 @@ class RenderContentCompilerTests: XCTestCase { } XCTAssertEqual(codeListing.syntax, "swift") - XCTAssertEqual(codeListing.wrap, 20) - XCTAssertEqual(codeListing.highlight, [2]) + XCTAssertEqual(codeListing.options?.wrap, 20) + XCTAssertEqual(codeListing.options?.highlight, [2]) } func testHighlight() async throws { @@ -455,7 +455,7 @@ class RenderContentCompilerTests: XCTestCase { } XCTAssertEqual(codeListing.syntax, "swift") - XCTAssertEqual(codeListing.highlight, [2]) + XCTAssertEqual(codeListing.options?.highlight, [2]) } func testHighlightNoFeatureFlag() async throws { @@ -483,7 +483,7 @@ class RenderContentCompilerTests: XCTestCase { } XCTAssertEqual(codeListing.syntax, "swift, highlight=[2]") - XCTAssertEqual(codeListing.highlight, []) + XCTAssertEqual(codeListing.options?.highlight, nil) } func testMultipleHighlight() async throws { @@ -513,7 +513,7 @@ class RenderContentCompilerTests: XCTestCase { } XCTAssertEqual(codeListing.syntax, "swift") - XCTAssertEqual(codeListing.highlight, [1, 2, 3]) + XCTAssertEqual(codeListing.options?.highlight, [1, 2, 3]) } func testMultipleHighlightMultipleStrikeout() async throws { @@ -543,8 +543,8 @@ class RenderContentCompilerTests: XCTestCase { } XCTAssertEqual(codeListing.syntax, "swift") - XCTAssertEqual(codeListing.highlight, [1, 2, 3]) - XCTAssertEqual(codeListing.strikeout, [3, 5]) + XCTAssertEqual(codeListing.options?.highlight, [1, 2, 3]) + XCTAssertEqual(codeListing.options?.strikeout, [3, 5]) } func testLanguageNotFirstOption() async throws { @@ -573,12 +573,12 @@ class RenderContentCompilerTests: XCTestCase { return } - XCTAssertEqual(codeListing.showLineNumbers, true) - XCTAssertEqual(codeListing.highlight, [1, 2, 3]) + XCTAssertEqual(codeListing.options?.showLineNumbers, true) + XCTAssertEqual(codeListing.options?.highlight, [1, 2, 3]) // we expect the language to be the first option in the language line, otherwise it remains nil. XCTAssertEqual(codeListing.syntax, nil) - XCTAssertEqual(codeListing.wrap, 20) - XCTAssertEqual(codeListing.strikeout, [3]) + XCTAssertEqual(codeListing.options?.wrap, 20) + XCTAssertEqual(codeListing.options?.strikeout, [3]) } func testUnorderedArrayOptions() async throws { @@ -607,7 +607,7 @@ class RenderContentCompilerTests: XCTestCase { return } - XCTAssertEqual(codeListing.highlight, [5, 3, 4]) - XCTAssertEqual(codeListing.strikeout, [3, 1]) + XCTAssertEqual(codeListing.options?.highlight, [5, 3, 4]) + XCTAssertEqual(codeListing.options?.strikeout, [3, 1]) } } diff --git a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift index 21389ac41..e7885428e 100644 --- a/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift +++ b/Tests/SwiftDocCTests/Utility/ListItemExtractorTests.swift @@ -514,7 +514,7 @@ class ListItemExtractorTests: XCTestCase { // ``` // Inner code block // ``` - .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, copyToClipboard: false, wrap: 0, highlight: [], strikeout: [], showLineNumbers: false)), + .codeListing(.init(syntax: nil, code: ["Inner code block"], metadata: nil, options: nil)), // > Warning: Inner aside, with ``ThirdNotFoundSymbol`` link .aside(.init(style: .init(asideKind: .warning), content: [ From e442144a7dc8bbefdb16c1841cafb2980b6788d5 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 19 Sep 2025 12:14:48 -0600 Subject: [PATCH 31/39] fix feature flag on new tests --- .../Rendering/RenderContentCompilerTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift index ef275cbfd..a111471da 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderContentCompilerTests.swift @@ -342,7 +342,7 @@ class RenderContentCompilerTests: XCTestCase { } func testShowLineNumbers() async throws { - enableFeatureFlag(\.isExperimentalCodeBlockEnabled) + 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)) @@ -370,7 +370,7 @@ class RenderContentCompilerTests: XCTestCase { } func testLowercaseShowLineNumbers() async throws { - enableFeatureFlag(\.isExperimentalCodeBlockEnabled) + 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)) @@ -517,7 +517,7 @@ class RenderContentCompilerTests: XCTestCase { } func testMultipleHighlightMultipleStrikeout() async throws { - enableFeatureFlag(\.isExperimentalCodeBlockEnabled) + 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)) @@ -548,7 +548,7 @@ class RenderContentCompilerTests: XCTestCase { } func testLanguageNotFirstOption() async throws { - enableFeatureFlag(\.isExperimentalCodeBlockEnabled) + 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)) @@ -582,7 +582,7 @@ class RenderContentCompilerTests: XCTestCase { } func testUnorderedArrayOptions() async throws { - enableFeatureFlag(\.isExperimentalCodeBlockEnabled) + 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)) From f3014a35cbab8b14b8c62f0a2251af203ac5329d Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 19 Sep 2025 13:30:18 -0600 Subject: [PATCH 32/39] remove optional return type --- .../SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index f0fc0fcf6..7ca011607 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -210,7 +210,7 @@ public enum RenderBlockContent: Equatable { } /// A function that parses array values on code block options from the language line string - static internal func parseCodeBlockOptionsArray(_ value: String?) -> [Int]? { + static internal func parseCodeBlockOptionsArray(_ value: String?) -> [Int] { guard var s = value?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return [] } if s.hasPrefix("[") && s.hasSuffix("]") { From d2cc4b47586b584a8036d7bad49291a60210c69e Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 24 Sep 2025 11:20:05 -0600 Subject: [PATCH 33/39] update JSON structure for extensibility --- .../Checkers/InvalidCodeBlockOption.swift | 4 +- .../Content/RenderBlockContent.swift | 103 +++++++++++---- .../Resources/RenderNode.spec.json | 47 +++++-- .../RenderContentCompilerTests.swift | 121 ++++++++++++++++-- 4 files changed, 228 insertions(+), 47 deletions(-) diff --git a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift index 6d08cedf7..e483fa76c 100644 --- a/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift +++ b/Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift @@ -63,7 +63,9 @@ internal struct InvalidCodeBlockOption: Checker { // code property ends in a newline. this gives us a bogus extra line. let lineCount: Int = codeBlock.code.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }).count - 1 - guard let indices = RenderBlockContent.CodeBlockOptions.parseCodeBlockOptionsArray(value) else { + let indices = RenderBlockContent.CodeBlockOptions.parseCodeBlockOptionsArray(value) + + if !value.isEmpty, indices.isEmpty { let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Could not parse \(token.rawValue.singleQuoted) indices from \(value.singleQuoted). Expected an integer (e.g. 3) or an array (e.g. [1, 3, 5])") problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) return diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 7ca011607..ab530f152 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -139,10 +139,36 @@ public enum RenderBlockContent: Equatable { public struct CodeBlockOptions: Equatable { public var language: String? public var copyToClipboard: Bool - public var wrap: Int - public var highlight: [Int] public var showLineNumbers: Bool - public var strikeout: [Int] + public var wrap: Int + public var lineAnnotations: [LineAnnotation] + + public struct Position: Equatable, Comparable, Codable { + public static func < (lhs: RenderBlockContent.CodeBlockOptions.Position, rhs: RenderBlockContent.CodeBlockOptions.Position) -> Bool { + if lhs.line == rhs.line, let lhsCharacter = lhs.character, let rhsCharacter = rhs.character { + return lhsCharacter < rhsCharacter + } + return lhs.line < rhs.line + } + + public init(line: Int, character: Int? = nil) { + self.line = line + self.character = character + } + + var line: Int + var character: Int? + } + + //public struct Range: Equatable, Codable { + // var start: Position + // var end: Position + //} + + public struct LineAnnotation: Equatable, Codable { + var style: String + var range: Range + } public enum OptionName: String, CaseIterable { case _nonFrozenEnum_useDefaultCase @@ -166,10 +192,9 @@ public enum RenderBlockContent: Equatable { public init() { self.language = "" self.copyToClipboard = true - self.wrap = 0 - self.highlight = [] self.showLineNumbers = false - self.strikeout = [] + self.wrap = 0 + self.lineAnnotations = [] } public init(parsingLanguageString language: String?) { @@ -186,27 +211,53 @@ public enum RenderBlockContent: Equatable { self.wrap = 0 } - if let highlightString = tokens.first(where: { $0.name == .highlight })?.value, - let highlightValue = Self.parseCodeBlockOptionsArray(highlightString) { - self.highlight = highlightValue - } else { - self.highlight = [] + var annotations: [LineAnnotation] = [] + + if let highlightString = tokens.first(where: { $0.name == .highlight })?.value { + let highlightValue = Self.parseCodeBlockOptionsArray(highlightString) + for line in highlightValue { + let pos = Position(line: line, character: nil) + let range = pos.. Date: Fri, 26 Sep 2025 11:55:35 -0600 Subject: [PATCH 34/39] update RenderNode.spec to reflect using Range in LineAnnotation --- .../Rendering/Content/RenderBlockContent.swift | 5 ----- .../Resources/RenderNode.spec.json | 16 ++++------------ 2 files changed, 4 insertions(+), 17 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index ab530f152..2dc491bb3 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -160,11 +160,6 @@ public enum RenderBlockContent: Equatable { var character: Int? } - //public struct Range: Equatable, Codable { - // var start: Position - // var end: Position - //} - public struct LineAnnotation: Equatable, Codable { var style: String var range: Range diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 25b9b4ce6..9e32b283a 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -789,18 +789,10 @@ "enum": ["highlight", "strikeout"] }, "range": { - "$ref": "#/components/schemas/CharacterRange" - } - } - }, - "CharacterRange": { - "type": "object", - "properties": { - "start": { - "$ref": "#/components/schemas/Position" - }, - "end": { - "$ref": "#/components/schemas/Position" + "type": "array", + "items": { + "$ref": "#/components/schemas/Position" + } } } }, From 7be0085129bc5c07d3108e2fd0b7458c3a591146 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 26 Sep 2025 14:44:07 -0600 Subject: [PATCH 35/39] update feature name --- features.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features.json b/features.json index 31e8b0e7d..014b9bf65 100644 --- a/features.json +++ b/features.json @@ -1,7 +1,7 @@ { "features": [ { - "name": "code-blocks" + "name": "code-block-annotations" }, { "name": "diagnostics-file" From 2be0322bba3ac56a63d7a73dc12fdf28c5b2d55e Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Tue, 30 Sep 2025 11:37:22 -0600 Subject: [PATCH 36/39] Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: David Rönnqvist --- .../Model/Rendering/Content/RenderBlockContent.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 2dc491bb3..beedb3a95 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -156,8 +156,8 @@ public enum RenderBlockContent: Equatable { self.character = character } - var line: Int - var character: Int? + public var line: Int + public var character: Int? } public struct LineAnnotation: Equatable, Codable { From 971df64e5b49b94fa139e9e37f8fb6edd5903073 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Tue, 30 Sep 2025 11:37:50 -0600 Subject: [PATCH 37/39] Update Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: David Rönnqvist --- .../Model/Rendering/Content/RenderBlockContent.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index beedb3a95..7490add17 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -161,8 +161,13 @@ public enum RenderBlockContent: Equatable { } public struct LineAnnotation: Equatable, Codable { - var style: String - var range: Range + public var style: String + public var range: Range + + public init(style: String, range: Range) { + self.style = style + self.range = range + } } public enum OptionName: String, CaseIterable { From de416ec3eb20129e19982d75cd38f01c0491ae7c Mon Sep 17 00:00:00 2001 From: DebugSteven Date: Tue, 30 Sep 2025 18:11:45 -0600 Subject: [PATCH 38/39] require LineAnnotation properties style and range --- .../SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 9e32b283a..4f25303ac 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -794,7 +794,11 @@ "$ref": "#/components/schemas/Position" } } - } + }, + "required": [ + "style", + "range" + ] }, "Position": { "type": "object", From 35a24b83b51114a0bcbb2d01819d43b6fbde91fb Mon Sep 17 00:00:00 2001 From: DebugSteven Date: Tue, 30 Sep 2025 20:30:46 -0600 Subject: [PATCH 39/39] fix CodeListing initializers in Snippet --- Sources/SwiftDocC/Semantics/Snippets/Snippet.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift index 5dc54c091..f156e5ac0 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -93,10 +93,10 @@ extension Snippet: RenderableDirectiveConvertible { // Make dedicated copies of each line because the RenderBlockContent.codeListing requires it. .map { String($0) } - return [RenderBlockContent.codeListing(.init(syntax: mixin.language, code: lines, metadata: nil))] + return [RenderBlockContent.codeListing(.init(syntax: mixin.language, code: lines, metadata: nil, options: nil))] } else { // Render the full snippet and its explanatory content. - let fullCode = RenderBlockContent.codeListing(.init(syntax: mixin.language, code: mixin.lines, metadata: nil)) + let fullCode = RenderBlockContent.codeListing(.init(syntax: mixin.language, code: mixin.lines, metadata: nil, options: nil)) var content: [any RenderContent] = resolvedSnippet.explanation?.children.flatMap { contentCompiler.visit($0) } ?? [] content.append(fullCode)