Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
*/

internal import Foundation
internal import Markdown

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

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

private var sourceFile: URL?

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

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

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

guard !tokens.isEmpty else { return }

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

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

if !matches.isEmpty {
let diagnostic = Diagnostic(source: sourceFile, severity: .warning, range: codeBlock.range, identifier: "org.swift.docc.InvalidCodeBlockOption", summary: "Unknown option \(token.singleQuoted) in code block.")
let possibleSolutions = matches.map { candidate in
Solution(
summary: "Replace \(token.singleQuoted) with \(candidate.singleQuoted).",
replacements: []
)
}
problems.append(Problem(diagnostic: diagnostic, possibleSolutions: possibleSolutions))
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,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 @@ -2453,7 +2454,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/isExperimentalCodeBlockAnnotationsEnabled``.
public var experimentalCodeBlockAnnotations: Bool?

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

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

enum CodingKeys: String, CodingKey, CaseIterable {
case experimentalOverloadedSymbolPresentation = "ExperimentalOverloadedSymbolPresentation"
case experimentalCodeBlockAnnotations = "ExperimentalCodeBlockAnnotations"
}

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

case .experimentalCodeBlockAnnotations:
self.experimentalCodeBlockAnnotations = try values.decode(Bool.self, forKey: flagName)
}
} else {
unknownFeatureFlags.append(flagName.stringValue)
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(experimentalCodeBlockAnnotations, forKey: .experimentalCodeBlockAnnotations)
}
}
}
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

public enum OptionName: String, CaseIterable {
case nocopy

init?(caseInsensitive raw: some StringProtocol) {
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 = true) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: should this default value be based on FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled?

Copy link
Contributor Author

@DebugSteven DebugSteven Sep 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure. I had intentionally written it to be on by default because I thought having a copy-to-clipboard button by default was wanted. But without the feature flag, users have no way of turning it off.
https://forums.swift.org/t/extending-docc-with-additional-metadata-to-provide-alternate-displays-for-code-blocks/81056/5

If it's best to turn this on only when the feature flag is present, I can make that change. Please just let me know.

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.isExperimentalCodeBlockAnnotationsEnabled
self = try .codeListing(.init(
syntax: container.decodeIfPresent(String.self, forKey: .syntax),
code: container.decode([String].self, forKey: .code),
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata)
))
metadata: container.decodeIfPresent(RenderContentMetadata.self, forKey: .metadata),
copyToClipboard: container.decodeIfPresent(Bool.self, forKey: .copyToClipboard) ?? copy
))
case .heading:
self = try .heading(.init(level: container.decode(Int.self, forKey: .level), text: container.decode(String.self, forKey: .text), anchor: container.decodeIfPresent(String.self, forKey: .anchor)))
case .orderedList:
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
36 changes: 35 additions & 1 deletion Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,41 @@ struct RenderContentCompiler: MarkupVisitor {

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

if FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled {

func parseLanguageString(_ input: String?) -> (lang: String? , tokens: [RenderBlockContent.CodeListing.OptionName]) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: In the long term it might be worth extracting this so that the warning and the rendering don't have behavioral differences. Since there's already follow up PRs that parse more things, it's probably better to extract this in those PRs rather than here.

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

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

let options = parseLanguageString(codeBlock.language)

let listing = RenderBlockContent.CodeListing(
syntax: options.lang ?? bundle.info.defaultCodeListingLanguage,
code: codeBlock.code.splitByNewlines,
metadata: nil,
copyToClipboard: !options.tokens.contains(.nocopy)
)

return [RenderBlockContent.codeListing(listing)]

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

mutating func visitHeading(_ heading: Heading) -> [any RenderContent] {
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.isExperimentalCodeBlockAnnotationsEnabled
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.isExperimentalCodeBlockAnnotationsEnabled
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 isExperimentalCodeBlockAnnotationsEnabled = 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.isExperimentalCodeBlockAnnotationsEnabled = convert.featureFlags.enableExperimentalCodeBlockAnnotations
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-annotations"),
help: "Support annotations for code blocks."
)
var enableExperimentalCodeBlockAnnotations = false

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

Expand Down Expand Up @@ -558,6 +564,14 @@ extension Docc {

}

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

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