Skip to content
62 changes: 62 additions & 0 deletions Sources/SwiftDocC/Checker/Checkers/InvalidCodeBlockOption.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
This source file is part of the Swift.org open source project

Copyright (c) 2025 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception

See https://swift.org/LICENSE.txt for license information
See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

public import Foundation
public import Markdown

/**
Code blocks can have a `nocopy` option after the \`\`\`, in the language line.
`nocopy` can be immediately after the \`\`\` or after a specified language and a comma (`,`).
*/
public struct InvalidCodeBlockOption: Checker {
public var problems = [Problem]()

/// Parsing options for code blocks
private let knownOptions = RenderBlockContent.CodeListing.knownOptions

private var sourceFile: URL?

/// Creates a new checker that detects documents with multiple titles.
///
/// - Parameter sourceFile: The URL to the documentation file that the checker checks.
public init(sourceFile: URL?) {
self.sourceFile = sourceFile
}

public mutating func visitCodeBlock(_ codeBlock: CodeBlock) {
let info = codeBlock.language?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !info.isEmpty else { return }

let tokens = info
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }

guard !tokens.isEmpty else { return }

for token in tokens {
// if the token is an exact match, we don't need to do anything
guard !knownOptions.contains(token) else { continue }

let matches = NearMiss.bestMatches(for: knownOptions, against: token)

if !matches.isEmpty {
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(token.singleQuoted) in code block.")
let possibleSolutions = matches.map { candidate in
Solution(
summary: "Replace \(token.singleQuoted) with \(candidate.singleQuoted).",
replacements: []
)
}
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ public class DocumentationContext {
MissingAbstract(sourceFile: source).any(),
NonOverviewHeadingChecker(sourceFile: source).any(),
SeeAlsoInTopicsHeadingChecker(sourceFile: source).any(),
InvalidCodeBlockOption(sourceFile: source).any(),
])
checker.visit(document)
diagnosticEngine.emit(checker.problems)
Expand Down Expand Up @@ -2650,7 +2651,6 @@ public class DocumentationContext {
}
}
}

/// A closure type getting the information about a reference in a context and returns any possible problems with it.
public typealias ReferenceCheck = (DocumentationContext, ResolvedTopicReference) -> [Problem]

Expand Down
13 changes: 13 additions & 0 deletions Sources/SwiftDocC/Infrastructure/Workspace/FeatureFlags+Info.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,20 @@ extension DocumentationBundle.Info {
self.unknownFeatureFlags = []
}

/// This feature flag corresponds to ``FeatureFlags/isExperimentalCodeBlockEnabled``.
public var experimentalCodeBlock: Bool?

public init(experimentalCodeBlock: Bool? = nil) {
self.experimentalCodeBlock = experimentalCodeBlock
self.unknownFeatureFlags = []
}

/// A list of decoded feature flag keys that didn't match a known feature flag.
public let unknownFeatureFlags: [String]

enum CodingKeys: String, CodingKey, CaseIterable {
case experimentalOverloadedSymbolPresentation = "ExperimentalOverloadedSymbolPresentation"
case experimentalCodeBlock = "ExperimentalCodeBlock"
}

struct AnyCodingKeys: CodingKey {
Expand All @@ -66,6 +75,9 @@ extension DocumentationBundle.Info {
switch codingKey {
case .experimentalOverloadedSymbolPresentation:
self.experimentalOverloadedSymbolPresentation = try values.decode(Bool.self, forKey: flagName)

case .experimentalCodeBlock:
self.experimentalCodeBlock = try values.decode(Bool.self, forKey: flagName)
}
} else {
unknownFeatureFlags.append(flagName.stringValue)
Expand All @@ -79,6 +91,7 @@ extension DocumentationBundle.Info {
var container = encoder.container(keyedBy: CodingKeys.self)

try container.encode(experimentalOverloadedSymbolPresentation, forKey: .experimentalOverloadedSymbolPresentation)
try container.encode(experimentalCodeBlock, forKey: .experimentalCodeBlock)
}
}
}
25 changes: 21 additions & 4 deletions Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,26 @@ public enum RenderBlockContent: Equatable {
public var code: [String]
/// Additional metadata for this code block.
public var metadata: RenderContentMetadata?
public var copyToClipboard: Bool = true

public enum OptionName: String, CaseIterable {
case nocopy

init?<S: StringProtocol>(caseInsensitive raw: S) {
self.init(rawValue: raw.lowercased())
}
}

public static var knownOptions: Set<String> {
Set(OptionName.allCases.map(\.rawValue))
}

/// Make a new `CodeListing` with the given data.
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?) {
public init(syntax: String?, code: [String], metadata: RenderContentMetadata?, copyToClipboard: Bool) {
self.syntax = syntax
self.code = code
self.metadata = metadata
self.copyToClipboard = copyToClipboard
}
}

Expand Down Expand Up @@ -697,7 +711,7 @@ extension RenderBlockContent.Table: Codable {
extension RenderBlockContent: Codable {
private enum CodingKeys: CodingKey {
case type
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start
case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata, start, copyToClipboard
case request, response
case header, rows
case numberOfColumns, columns
Expand All @@ -719,11 +733,13 @@ extension RenderBlockContent: Codable {
}
self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content)))
case .codeListing:
let copy = FeatureFlags.current.isExperimentalCodeBlockEnabled
self = try .codeListing(.init(
syntax: container.decodeIfPresent(String.self, forKey: .syntax),
code: container.decode([String].self, forKey: .code),
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
))
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata),
copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy
))
case .heading:
self = try .heading(.init(level: container.decode(Int.self, forKey: .level), text: container.decode(String.self, forKey: .text), anchor: container.decodeIfPresent(String.self, forKey: .anchor)))
case .orderedList:
Expand Down Expand Up @@ -826,6 +842,7 @@ extension RenderBlockContent: Codable {
try container.encode(l.syntax, forKey: .syntax)
try container.encode(l.code, forKey: .code)
try container.encodeIfPresent(l.metadata, forKey: .metadata)
try container.encode(l.copyToClipboard, forKey: .copyToClipboard)
case .heading(let h):
try container.encode(h.level, forKey: .level)
try container.encode(h.text, forKey: .text)
Expand Down
44 changes: 43 additions & 1 deletion Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,49 @@ struct RenderContentCompiler: MarkupVisitor {

mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> [any RenderContent] {
// Default to the bundle's code listing syntax if one is not explicitly declared in the code block.
return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil))]

if FeatureFlags.current.isExperimentalCodeBlockEnabled {

func parseLanguageString(_ input: String?) -> (lang: String? , tokens: [RenderBlockContent.CodeListing.OptionName]) {
guard let input else { return (lang: nil, tokens: []) }
let parts = input
.split(separator: ",")
.map { $0.trimmingCharacters(in: .whitespaces) }
var lang: String? = nil
var options: [RenderBlockContent.CodeListing.OptionName] = []

for part in parts {
if let opt = RenderBlockContent.CodeListing.OptionName(caseInsensitive: part) {
options.append(opt)
} else if lang == nil {
lang = String(part)
}
}
return (lang, options)
}

let options = parseLanguageString(codeBlock.language)

var listing = RenderBlockContent.CodeListing(
syntax: options.lang ?? bundle.info.defaultCodeListingLanguage,
code: codeBlock.code.splitByNewlines,
metadata: nil,
copyToClipboard: true // default value
)

// apply code block options
for option in options.tokens {
switch option {
case .nocopy:
listing.copyToClipboard = false
}
}

return [RenderBlockContent.codeListing(listing)]

} else {
return [RenderBlockContent.codeListing(.init(syntax: codeBlock.language ?? bundle.info.defaultCodeListingLanguage, code: codeBlock.code.splitByNewlines, metadata: nil, copyToClipboard: false))]
}
}

mutating func visitHeading(_ heading: Heading) -> [any RenderContent] {
Expand Down
6 changes: 4 additions & 2 deletions Sources/SwiftDocC/Semantics/Snippets/Snippet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,13 @@ extension Snippet: RenderableDirectiveConvertible {
let lines = snippetMixin.lines[lineRange]
let minimumIndentation = lines.map { $0.prefix { $0.isWhitespace }.count }.min() ?? 0
let trimmedLines = lines.map { String($0.dropFirst(minimumIndentation)) }
return [RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: trimmedLines, metadata: nil))]
let copy = FeatureFlags.current.isExperimentalCodeBlockEnabled
return [RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: trimmedLines, metadata: nil, copyToClipboard: copy))]
} else {
// Render the whole snippet with its explanation content.
let docCommentContent = snippetEntity.markup.children.flatMap { contentCompiler.visit($0) }
let code = RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: snippetMixin.lines, metadata: nil))
let copy = FeatureFlags.current.isExperimentalCodeBlockEnabled
let code = RenderBlockContent.codeListing(.init(syntax: snippetMixin.language, code: snippetMixin.lines, metadata: nil, copyToClipboard: copy))
return docCommentContent + [code]
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,9 @@
},
"metadata": {
"$ref": "#/components/schemas/RenderContentMetadata"
},
"copyToClipboard": {
"type": "boolean"
}
}
},
Expand Down
5 changes: 4 additions & 1 deletion Sources/SwiftDocC/Utility/FeatureFlags.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ public struct FeatureFlags: Codable {
/// The current feature flags that Swift-DocC uses to conditionally enable
/// (usually experimental) behavior in Swift-DocC.
public static var current = FeatureFlags()


/// Whether or not experimental annotation of code blocks is enabled.
public var isExperimentalCodeBlockEnabled = false

/// Whether or not experimental support for device frames on images and video is enabled.
public var isExperimentalDeviceFrameSupportEnabled = false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ extension ConvertAction {
public init(fromConvertCommand convert: Docc.Convert, withFallbackTemplate fallbackTemplateURL: URL? = nil) throws {
var standardError = LogHandle.standardError
let outOfProcessResolver: OutOfProcessReferenceResolver?

FeatureFlags.current.isExperimentalCodeBlockEnabled = convert.enableExperimentalCodeBlock
FeatureFlags.current.isExperimentalDeviceFrameSupportEnabled = convert.enableExperimentalDeviceFrameSupport
FeatureFlags.current.isExperimentalLinkHierarchySerializationEnabled = convert.enableExperimentalLinkHierarchySerialization
FeatureFlags.current.isExperimentalOverloadedSymbolPresentationEnabled = convert.enableExperimentalOverloadedSymbolPresentation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,13 @@ extension Docc {
struct FeatureFlagOptions: ParsableArguments {
@Flag(help: "Allows for custom templates, like `header.html`.")
var experimentalEnableCustomTemplates = false


@Flag(
name: .customLong("enable-experimental-code-block"),
help: "Support copy-to-clipboard for code blocks."
)
var enableExperimentalCodeBlock = false

@Flag(help: .hidden)
var enableExperimentalDeviceFrameSupport = false

Expand Down Expand Up @@ -557,6 +563,14 @@ extension Docc {

}

/// A user-provided value that is true if the user enables experimental support for code block annotation.
///
/// Defaults to false.
public var enableExperimentalCodeBlock: Bool {
get { featureFlags.enableExperimentalCodeBlock }
set { featureFlags.enableExperimentalCodeBlock = newValue}
}

/// A user-provided value that is true if the user enables experimental support for device frames.
///
/// Defaults to false.
Expand Down
Loading