From 1f90d7ad4f175e4497d044e52e441fac42b53081 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 6 Aug 2025 13:38:47 -0600 Subject: [PATCH 01/30] 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 2e570f93719005825e8ffbe21a29d196d9220239 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 11 Aug 2025 11:20:04 -0600 Subject: [PATCH 02/30] copy by default --- .../formatting-your-documentation-content.md | 17 ++++++++++------- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +++++----- 3 files changed, 16 insertions(+), 13 deletions(-) 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!"), ]) From 33d929c89df2d203f188024579116494128c63e7 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 11 Aug 2025 15:30:50 -0600 Subject: [PATCH 03/30] add feature flag 'enable-experimental-code-block-annotations' for copy-to-clipboard and other code block annotations --- .../Model/Rendering/RenderContentCompiler.swift | 4 ++-- .../ConvertAction+CommandInitialization.swift | 2 +- .../Model/RenderContentMetadataTests.swift | 2 +- .../Model/RenderNodeSerializationTests.swift | 10 +++++----- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index e79e6fdfc..d88373c40 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -68,10 +68,10 @@ struct RenderContentCompiler: MarkupVisitor { return (lang, options) } - let options = parseLanguageString(codeBlock.language) + let (lang, options) = parseLanguageString(codeBlock.language) let listing = RenderBlockContent.CodeListing( - syntax: options.lang ?? bundle.info.defaultCodeListingLanguage, + syntax: lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: !options.tokens.contains(.nocopy) diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift index c23c8c55b..4d7272d3a 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/ActionExtensions/ConvertAction+CommandInitialization.swift @@ -19,7 +19,7 @@ extension ConvertAction { public init(fromConvertCommand convert: Docc.Convert, withFallbackTemplate fallbackTemplateURL: URL? = nil) throws { var standardError = LogHandle.standardError let outOfProcessResolver: OutOfProcessReferenceResolver? - FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled = convert.featureFlags.enableExperimentalCodeBlockAnnotations + FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled = convert.enableExperimentalCodeBlockAnnotations FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled = convert.enableExperimentalDeviceFrameSupport FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = convert.enableExperimentalLinkHierarchySerialization FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation 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 9315bdbe9000b1762682adb356eb97c1fe0678d3 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 15 Aug 2025 15:34:21 -0600 Subject: [PATCH 04/30] add more tests, remove docs, code cleanup --- .../formatting-your-documentation-content.md | 21 ------------------- 1 file changed, 21 deletions(-) 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: From 20b0c08373cfaff414aad8bce7405ebc42ba8485 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Thu, 21 Aug 2025 15:40:05 -0600 Subject: [PATCH 05/30] add code block options onto RenderBlockContent.CodeListing --- .../Model/Rendering/Content/RenderBlockContent.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 85ac03182..c66eba26c 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -138,6 +138,18 @@ public enum RenderBlockContent: Equatable { Set(OptionName.allCases.map(\.rawValue)) } + 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 = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled) { self.syntax = syntax From 73c7416d0a41a5b41900569cdb9243433b81dfd1 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 17 Sep 2025 12:01:36 -0600 Subject: [PATCH 06/30] remaining PR feedback --- .../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 c66eba26c..d72ca445d 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -141,7 +141,7 @@ public enum RenderBlockContent: Equatable { public enum OptionName: String, CaseIterable { case nocopy - init?(caseInsensitive raw: S) { + init?(caseInsensitive raw: some StringProtocol) { self.init(rawValue: raw.lowercased()) } } From 0a7f55f9f54db81466a789e980174298d1b4ea6a Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 11 Aug 2025 11:20:04 -0600 Subject: [PATCH 07/30] 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 01195f94a587e5bb729fc8a996c287a73f860130 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 11 Aug 2025 15:30:50 -0600 Subject: [PATCH 08/30] 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 f3c9328db937dfcfaa0d53b6ee41d18d5ee6be0a Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 8 Aug 2025 10:50:27 -0600 Subject: [PATCH 09/30] 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 d72ca445d..7a954d30f 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 @@ -151,11 +153,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 } } @@ -723,7 +727,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 @@ -750,8 +754,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: @@ -855,6 +861,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 d88373c40..c7cc1c378 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 10/30] 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 1d25949bc401f50405a88313b69d3fab36b277c8 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 13 Aug 2025 17:25:50 -0600 Subject: [PATCH 11/30] 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 7a954d30f..9216f402c 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 c7cc1c378..3441b398e 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 (lang, 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 81f26eb5a838b4372200ea7dc6f5cb074e0fac17 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Tue, 26 Aug 2025 18:30:19 -0600 Subject: [PATCH 12/30] change parsing to handle values after = and arrays --- .../Checkers/InvalidCodeBlockOption.swift | 28 +++--- .../Content/RenderBlockContent.swift | 1 + .../Rendering/RenderContentCompiler.swift | 7 +- .../Utility/ParseLanguageString.swift | 90 +++++++++++++++++++ .../InvalidCodeBlockOptionTests.swift | 4 +- .../RenderContentCompilerTests.swift | 27 +++++- 6 files changed, 136 insertions(+), 21 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 9216f402c..735c7e6c2 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 3441b398e..772871196 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -49,6 +49,7 @@ 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: []) } @@ -92,7 +93,7 @@ struct RenderContentCompiler: MarkupVisitor { let (lang, options) = parseLanguageString(codeBlock.language) - let listing = RenderBlockContent.CodeListing( + var listing = RenderBlockContent.CodeListing( syntax: lang ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, @@ -102,7 +103,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 +115,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 13/30] 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 735c7e6c2..2a589a2db 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) { @@ -156,13 +158,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 } } @@ -730,7 +733,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 @@ -759,7 +762,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))) @@ -866,6 +870,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 772871196..6c48d9c1e 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -98,8 +98,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 @@ -114,7 +115,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 } @@ -123,7 +126,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 456ba3af9d6237c3969fe26ba36c6dd01a91d054 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 29 Aug 2025 15:45:09 -0600 Subject: [PATCH 14/30] 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 d2f3b0624837e97dd5e47bad0ee00200ceb8a43a Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 29 Aug 2025 18:13:42 -0600 Subject: [PATCH 15/30] 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 52212ebe712e5905814f2288e3f8375e93a0b686 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 5 Sep 2025 09:26:58 -0600 Subject: [PATCH 16/30] 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 2a589a2db..a9498c1ed 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 @@ -158,13 +160,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 } } @@ -733,7 +736,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 @@ -763,7 +766,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))) @@ -871,6 +875,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 6c48d9c1e..0e9118792 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -100,7 +100,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 @@ -118,6 +119,8 @@ struct RenderContentCompiler: MarkupVisitor { listing.highlight = parseCodeBlockOptionArray(value) ?? [] case .strikeout: listing.strikeout = parseCodeBlockOptionArray(value) ?? [] + case .showLineNumbers: + listing.showLineNumbers = true case .unknown: break } @@ -126,7 +129,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 0e010ac163fa6f100bb20373e6b66ab7f8c76040 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 5 Sep 2025 17:02:09 -0600 Subject: [PATCH 17/30] 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 0e9118792..0a49c5978 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -101,7 +101,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 8be4df3b7f8548e3e8603502537a40dba7970f84 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Mon, 8 Sep 2025 16:26:03 -0600 Subject: [PATCH 18/30] 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 83737dd2ec234b5fbad37d153a580624facc91d5 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Thu, 18 Sep 2025 20:30:58 -0600 Subject: [PATCH 19/30] PR feedback --- .../Checkers/InvalidCodeBlockOption.swift | 10 +- .../Content/RenderBlockContent.swift | 185 +++++++++++++++--- .../Rendering/RenderContentCompiler.swift | 79 +------- .../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, 197 insertions(+), 229 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 a9498c1ed..a858ffbd2 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,29 +162,141 @@ public enum RenderBlockContent: Equatable { Set(OptionName.allCases.map(\.rawValue)) } - public enum OptionName: String, CaseIterable { - case nocopy + // empty initializer with default values + public init() { + self.language = "" + self.copyToClipboard = true + self.wrap = 0 + self.highlight = [] + self.showLineNumbers = false + self.strikeout = [] + } - init?(caseInsensitive raw: some StringProtocol) { - self.init(rawValue: raw.lowercased()) + 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 } - } - public static var knownOptions: Set { - Set(OptionName.allCases.map(\.rawValue)) + 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 = [] + } } - /// 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 + public init(copyToClipboard: Bool = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled, wrap: Int, highlight: [Int], strikeout: [Int], showLineNumbers: Bool = false) { 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.. (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 (lang, options) = parseLanguageString(codeBlock.language) - - var listing = RenderBlockContent.CodeListing( - syntax: lang ?? bundle.info.defaultCodeListingLanguage, + let codeBlockOptions = RenderBlockContent.CodeBlockOptions(parsingLanguageString: codeBlock.language) + let listing = RenderBlockContent.CodeListing( + syntax: codeBlockOptions.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, - copyToClipboard: !options.tokens.contains(.nocopy), - wrap: 0, // default value - highlight: [Int](), // default value - strikeout: [Int](), // default value - showLineNumbers: false // default value + options: codeBlockOptions ) - // apply code block options - for (option, value) in tokens { - switch option { - case .nocopy: - listing.copyToClipboard = false - case .wrap: - if let value, let intValue = Int(value) { - listing.wrap = intValue - } else { - listing.wrap = 0 - } - case .highlight: - listing.highlight = parseCodeBlockOptionArray(value) ?? [] - case .strikeout: - listing.strikeout = parseCodeBlockOptionArray(value) ?? [] - case .showLineNumbers: - listing.showLineNumbers = true - case .unknown: - break - } - } - 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](), showLineNumbers: false))] + return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, options: nil))] } } diff --git a/Sources/SwiftDocC/Utility/ParseLanguageString.swift b/Sources/SwiftDocC/Utility/ParseLanguageString.swift deleted file mode 100644 index d88b69815..000000000 --- a/Sources/SwiftDocC/Utility/ParseLanguageString.swift +++ /dev/null @@ -1,97 +0,0 @@ -/* - 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 -*/ -/// 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("]") { - 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 12960a0ad8cb5442702c650e6e9ac8545ebac20c Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 19 Sep 2025 12:14:48 -0600 Subject: [PATCH 20/30] 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 9649d2747cdd97480728257c8dfb9d4a0adbb05f Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 19 Sep 2025 13:30:18 -0600 Subject: [PATCH 21/30] 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 a858ffbd2..d7a9c473a 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 38f4ef3bac34d5c26e0592c9e216bbfd3cb1313c Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Wed, 24 Sep 2025 11:20:05 -0600 Subject: [PATCH 22/30] 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 d7a9c473a..9d5988b0c 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 23/30] 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 9d5988b0c..cb0891e01 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 0c57de0f9e1d815f8ed90e6e9f6d048590a77759 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Fri, 26 Sep 2025 14:44:07 -0600 Subject: [PATCH 24/30] 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 80280028d705c96567a9a3e0699480adb5585496 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Tue, 30 Sep 2025 11:37:22 -0600 Subject: [PATCH 25/30] 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 cb0891e01..593b5cedf 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 074081ad149a3c07d74d668fc24dcd9852e06b81 Mon Sep 17 00:00:00 2001 From: Jesse Haigh Date: Tue, 30 Sep 2025 11:37:50 -0600 Subject: [PATCH 26/30] 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 593b5cedf..b3fb77b29 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 48d1c46e1bf8a2148092565897e47c1821e46182 Mon Sep 17 00:00:00 2001 From: DebugSteven Date: Tue, 30 Sep 2025 18:11:45 -0600 Subject: [PATCH 27/30] 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 0d80729ec0687328dd399e49085351edfc0d5735 Mon Sep 17 00:00:00 2001 From: DebugSteven Date: Tue, 30 Sep 2025 20:30:46 -0600 Subject: [PATCH 28/30] 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) From 45c659284a9cb23c5788ea927ed766b6756ab158 Mon Sep 17 00:00:00 2001 From: DebugSteven Date: Mon, 6 Oct 2025 13:36:30 -0600 Subject: [PATCH 29/30] fix typo --- .../ArgumentParsing/Subcommands/Convert.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift index aa427bdb1..a33715a62 100644 --- a/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift +++ b/Sources/SwiftDocCUtilities/ArgumentParsing/Subcommands/Convert.swift @@ -567,7 +567,7 @@ extension Docc { /// A user-provided value that is true if the user enables experimental support for code block annotation. /// /// Defaults to false. - public var enableExperimentalCodeBlocAnnotations: Bool { + public var enableExperimentalCodeBlockAnnotations: Bool { get { featureFlags.enableExperimentalCodeBlockAnnotations } set { featureFlags.enableExperimentalCodeBlockAnnotations = newValue} } From 4cb5248dbdb92ad6fd997bfd10647682287eab31 Mon Sep 17 00:00:00 2001 From: DebugSteven Date: Mon, 6 Oct 2025 13:53:07 -0600 Subject: [PATCH 30/30] add copy-to-clipboard button back to snippets when feature flag is present --- .../Model/Rendering/Content/RenderBlockContent.swift | 2 +- Sources/SwiftDocC/Semantics/Snippets/Snippet.swift | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index b3fb77b29..2b74a95e3 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -191,7 +191,7 @@ public enum RenderBlockContent: Equatable { // empty initializer with default values public init() { self.language = "" - self.copyToClipboard = true + self.copyToClipboard = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled self.showLineNumbers = false self.wrap = 0 self.lineAnnotations = [] diff --git a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift index f156e5ac0..a6f7f34db 100644 --- a/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift +++ b/Sources/SwiftDocC/Semantics/Snippets/Snippet.swift @@ -92,11 +92,14 @@ extension Snippet: RenderableDirectiveConvertible { .linesWithoutLeadingWhitespace() // Make dedicated copies of each line because the RenderBlockContent.codeListing requires it. .map { String($0) } + + let options = RenderBlockContent.CodeBlockOptions() - return [RenderBlockContent.codeListing(.init(syntax: mixin.language, code: lines, metadata: nil, options: nil))] + return [RenderBlockContent.codeListing(.init(syntax: mixin.language, code: lines, metadata: nil, options: options))] } else { // Render the full snippet and its explanatory content. - let fullCode = RenderBlockContent.codeListing(.init(syntax: mixin.language, code: mixin.lines, metadata: nil, options: nil)) + let options = RenderBlockContent.CodeBlockOptions() + let fullCode = RenderBlockContent.codeListing(.init(syntax: mixin.language, code: mixin.lines, metadata: nil, options: options)) var content: [any RenderContent] = resolvedSnippet.explanation?.children.flatMap { contentCompiler.visit($0) } ?? [] content.append(fullCode)