diff --git a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift index 9e9b07c280..f68165ff75 100644 --- a/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift +++ b/Sources/SwiftDocC/Infrastructure/DocumentationContext.swift @@ -127,6 +127,9 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { /// The graph of all the documentation content and their relationships to each other. var topicGraph = TopicGraph() + + /// User-provided global options for this documentation conversion. + var options: Options? /// A value to control whether the set of manually curated references found during bundle registration should be stored. Defaults to `false`. Setting this property to `false` clears any stored references from `manuallyCuratedReferences`. public var shouldStoreManuallyCuratedReferences: Bool = false { @@ -1981,6 +1984,43 @@ public class DocumentationContext: DocumentationContextDataProviderDelegate { let (technologies, tutorials, tutorialArticles, allArticles) = result var (otherArticles, rootPageArticles) = splitArticles(allArticles) + let globalOptions = (allArticles + uncuratedDocumentationExtensions.values.flatMap { $0 }).compactMap { article in + return article.value.options[.global] + } + + if globalOptions.count > 1 { + let extraGlobalOptionsProblems = globalOptions.map { extraOptionsDirective -> Problem in + let diagnostic = Diagnostic( + source: extraOptionsDirective.originalMarkup.nameLocation?.source, + severity: .warning, + range: extraOptionsDirective.originalMarkup.range, + identifier: "org.swift.docc.DuplicateGlobalOptions", + summary: "Duplicate \(extraOptionsDirective.scope) \(Options.directiveName.singleQuoted) directive", + explanation: """ + A DocC catalog can only contain a single \(Options.directiveName.singleQuoted) \ + directive with the \(extraOptionsDirective.scope.rawValue.singleQuoted) scope. + """ + ) + + guard let range = extraOptionsDirective.originalMarkup.range else { + return Problem(diagnostic: diagnostic) + } + + let solution = Solution( + summary: "Remove extraneous \(extraOptionsDirective.scope) \(Options.directiveName.singleQuoted) directive", + replacements: [ + Replacement(range: range, replacement: "") + ] + ) + + return Problem(diagnostic: diagnostic, possibleSolutions: [solution]) + } + + diagnosticEngine.emit(extraGlobalOptionsProblems) + } else { + options = globalOptions.first + } + if LinkResolutionMigrationConfiguration.shouldSetUpHierarchyBasedLinkResolver { hierarchyBasedLinkResolver = hierarchyBasedResolver hierarchyBasedResolver.addMappingForRoots(bundle: bundle) diff --git a/Sources/SwiftDocC/Infrastructure/Symbol Graph/GeneratedDocumentationTopics.swift b/Sources/SwiftDocC/Infrastructure/Symbol Graph/GeneratedDocumentationTopics.swift index 9f7ac282f1..7d4cce137a 100644 --- a/Sources/SwiftDocC/Infrastructure/Symbol Graph/GeneratedDocumentationTopics.swift +++ b/Sources/SwiftDocC/Infrastructure/Symbol Graph/GeneratedDocumentationTopics.swift @@ -185,7 +185,7 @@ enum GeneratedDocumentationTopics { availableSourceLanguages: automaticCurationSourceLanguages, name: DocumentationNode.Name.conceptual(title: title), markup: Document(parsing: ""), - semantic: Article(markup: nil, metadata: nil, redirects: nil) + semantic: Article(markup: nil, metadata: nil, redirects: nil, options: [:]) ) let collectionTaskGroups = try AutomaticCuration.topics(for: temporaryCollectionNode, withTrait: nil, context: context) diff --git a/Sources/SwiftDocC/Infrastructure/Topic Graph/AutomaticCuration.swift b/Sources/SwiftDocC/Infrastructure/Topic Graph/AutomaticCuration.swift index f6632f5dcc..30b40fc282 100644 --- a/Sources/SwiftDocC/Infrastructure/Topic Graph/AutomaticCuration.swift +++ b/Sources/SwiftDocC/Infrastructure/Topic Graph/AutomaticCuration.swift @@ -123,6 +123,14 @@ public struct AutomaticCuration { renderContext: RenderContext?, renderer: DocumentationContentRenderer ) throws -> TaskGroup? { + if let automaticSeeAlsoOption = node.options?.automaticSeeAlsoBehavior + ?? context.options?.automaticSeeAlsoBehavior + { + guard automaticSeeAlsoOption == .siblingPages else { + return nil + } + } + // First try getting the canonical path from a render context, default to the documentation context guard let canonicalPath = renderContext?.store.content(for: node.reference)?.canonicalPath ?? context.pathsTo(node.reference).first, !canonicalPath.isEmpty else { diff --git a/Sources/SwiftDocC/Model/DocumentationMarkup.swift b/Sources/SwiftDocC/Model/DocumentationMarkup.swift index 1d51af8e05..da47867501 100644 --- a/Sources/SwiftDocC/Model/DocumentationMarkup.swift +++ b/Sources/SwiftDocC/Model/DocumentationMarkup.swift @@ -146,7 +146,7 @@ struct DocumentationMarkup { // Found deprecation notice in the abstract. deprecation = MarkupContainer(directive.children) return - } else if directive.name == Comment.directiveName || directive.name == Metadata.directiveName { + } else if directive.name == Comment.directiveName || directive.name == Metadata.directiveName || directive.name == Options.directiveName { // These directives don't affect content so they shouldn't break us out of // the automatic abstract section. return diff --git a/Sources/SwiftDocC/Model/DocumentationNode.swift b/Sources/SwiftDocC/Model/DocumentationNode.swift index a1a1703b00..bf7f5890e2 100644 --- a/Sources/SwiftDocC/Model/DocumentationNode.swift +++ b/Sources/SwiftDocC/Model/DocumentationNode.swift @@ -61,6 +61,11 @@ public struct DocumentationNode { /// If true, the node was created implicitly and should not generally be rendered as a page of documentation. public var isVirtual: Bool + + /// The authored options for this node. + /// + /// Allows for control of settings such as automatic see also generation. + public var options: Options? /// A discrete unit of documentation struct DocumentationChunk { @@ -137,6 +142,13 @@ public struct DocumentationNode { self.platformNames = platformNames self.docChunks = [DocumentationChunk(source: .sourceCode(location: nil), markup: markup)] self.isVirtual = isVirtual + + if let article = semantic as? Article { + self.options = article.options[.local] + } else { + self.options = nil + } + updateAnchorSections() } @@ -351,6 +363,8 @@ public struct DocumentationNode { ) } + options = documentationExtension?.options[.local] + updateAnchorSections() } @@ -603,6 +617,7 @@ public struct DocumentationNode { self.docChunks = [DocumentationChunk(source: .documentationExtension, markup: articleMarkup)] self.markup = articleMarkup self.isVirtual = false + self.options = article.options[.local] updateAnchorSections() } diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNode.swift b/Sources/SwiftDocC/Model/Rendering/RenderNode.swift index a150c592fa..5ba53d0cc1 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNode.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNode.swift @@ -162,6 +162,9 @@ public struct RenderNode: VariantContainer { /// The variants of the primary content sections of the node, which are the main sections of a reference documentation node. public var primaryContentSectionsVariants: [VariantCollection] = [] + /// The visual style that should be used when rendering this page's Topics section. + public var topicSectionsStyle: TopicsSectionStyle + /// The default Topics sections of this documentation node, which contain links to useful related documentation nodes. public var topicSections: [TaskGroupRenderSection] { get { getVariantDefaultValue(keyPath: \.topicSectionsVariants) } @@ -234,6 +237,7 @@ public struct RenderNode: VariantContainer { public init(identifier: ResolvedTopicReference, kind: Kind) { self.identifier = identifier self.kind = kind + self.topicSectionsStyle = .list } // MARK: Tutorials nodes diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderNode+Codable.swift b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderNode+Codable.swift index 8aeda1b688..c1ef55aaac 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderNode+Codable.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNode/RenderNode+Codable.swift @@ -13,7 +13,7 @@ import Foundation extension RenderNode: Codable { private enum CodingKeys: CodingKey { case schemaVersion, identifier, sections, references, metadata, kind, hierarchy - case abstract, topicSections, defaultImplementationsSections, primaryContentSections, relationshipsSections, declarationSections, seeAlsoSections, returnsSection, parametersSection, sampleCodeDownload, downloadNotAvailableSummary, deprecationSummary, diffAvailability, interfaceLanguage, variants, variantOverrides + case abstract, topicSections, topicSectionsStyle, defaultImplementationsSections, primaryContentSections, relationshipsSections, declarationSections, seeAlsoSections, returnsSection, parametersSection, sampleCodeDownload, downloadNotAvailableSummary, deprecationSummary, diffAvailability, interfaceLanguage, variants, variantOverrides } public init(from decoder: Decoder) throws { @@ -26,6 +26,7 @@ extension RenderNode: Codable { metadata = try container.decode(RenderMetadata.self, forKey: .metadata) kind = try container.decode(Kind.self, forKey: .kind) hierarchy = try container.decodeIfPresent(RenderHierarchy.self, forKey: .hierarchy) + topicSectionsStyle = try container.decodeIfPresent(TopicsSectionStyle.self, forKey: .topicSectionsStyle) ?? .list primaryContentSectionsVariants = try container.decodeVariantCollectionArrayIfPresent( ofValueType: CodableContentSection?.self, @@ -79,6 +80,9 @@ extension RenderNode: Codable { try container.encode(metadata, forKey: .metadata) try container.encode(kind, forKey: .kind) try container.encode(hierarchy, forKey: .hierarchy) + if topicSectionsStyle != .list { + try container.encode(topicSectionsStyle, forKey: .topicSectionsStyle) + } try container.encodeVariantCollection(abstractVariants, forKey: .abstract, encoder: encoder) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift index 40849662e6..e10dd0d840 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderNodeTranslator.swift @@ -703,12 +703,15 @@ public struct RenderNodeTranslator: SemanticVisitor { return sections } ?? .init(defaultValue: []) - - if node.topicSections.isEmpty { - // Set an eyebrow for articles - node.metadata.roleHeading = "Article" + node.topicSectionsStyle = topicsSectionStyle(for: documentationNode) + + if shouldCreateAutomaticRoleHeading(for: documentationNode) { + if node.topicSections.isEmpty { + // Set an eyebrow for articles + node.metadata.roleHeading = "Article" + } + node.metadata.role = contentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue } - node.metadata.role = contentRenderer.roleForArticle(article, nodeKind: documentationNode.kind).rawValue node.seeAlsoSectionsVariants = VariantCollection<[TaskGroupRenderSection]>( from: documentationNode.availableVariantTraits, @@ -1038,6 +1041,39 @@ public struct RenderNodeTranslator: SemanticVisitor { return reference } + private func shouldCreateAutomaticRoleHeading(for node: DocumentationNode) -> Bool { + var shouldCreateAutomaticRoleHeading = true + if let automaticTitleHeadingOption = node.options?.automaticTitleHeadingBehavior + ?? context.options?.automaticTitleHeadingBehavior + { + shouldCreateAutomaticRoleHeading = automaticTitleHeadingOption == .pageKind + } + + return shouldCreateAutomaticRoleHeading + } + + private func topicsSectionStyle(for node: DocumentationNode) -> RenderNode.TopicsSectionStyle { + let topicsVisualStyleOption: TopicsVisualStyle.Style + if let topicsSectionStyleOption = node.options?.topicsVisualStyle + ?? context.options?.topicsVisualStyle + { + topicsVisualStyleOption = topicsSectionStyleOption + } else { + topicsVisualStyleOption = .list + } + + switch topicsVisualStyleOption { + case .list: + return .list + case .compactGrid: + return .compactGrid + case .detailedGrid: + return .detailedGrid + case .hidden: + return .hidden + } + } + public mutating func visitSymbol(_ symbol: Symbol) -> RenderTree? { let documentationNode = try! context.entity(with: identifier) @@ -1095,10 +1131,13 @@ public struct RenderNodeTranslator: SemanticVisitor { node.metadata.requiredVariants = VariantCollection(from: symbol.isRequiredVariants) ?? .init(defaultValue: false) node.metadata.role = contentRenderer.role(for: documentationNode.kind).rawValue - node.metadata.roleHeadingVariants = VariantCollection(from: symbol.roleHeadingVariants) node.metadata.titleVariants = VariantCollection(from: symbol.titleVariants) node.metadata.externalIDVariants = VariantCollection(from: symbol.externalIDVariants) + if shouldCreateAutomaticRoleHeading(for: documentationNode) { + node.metadata.roleHeadingVariants = VariantCollection(from: symbol.roleHeadingVariants) + } + node.metadata.symbolKindVariants = VariantCollection(from: symbol.kindVariants) { _, kindVariants in kindVariants.identifier.renderingIdentifier } ?? .init(defaultValue: nil) @@ -1308,6 +1347,8 @@ public struct RenderNodeTranslator: SemanticVisitor { return sections } ?? .init(defaultValue: []) + node.topicSectionsStyle = topicsSectionStyle(for: documentationNode) + node.defaultImplementationsSectionsVariants = VariantCollection<[TaskGroupRenderSection]>( from: symbol.defaultImplementationsVariants, symbol.relationshipsVariants diff --git a/Sources/SwiftDocC/Model/Rendering/TopicsSectionStyle.swift b/Sources/SwiftDocC/Model/Rendering/TopicsSectionStyle.swift new file mode 100644 index 0000000000..1543337d55 --- /dev/null +++ b/Sources/SwiftDocC/Model/Rendering/TopicsSectionStyle.swift @@ -0,0 +1,30 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2022 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 +*/ + +extension RenderNode { + /// The rendering style of the topics section. + public enum TopicsSectionStyle: String, Codable { + /// A list of the page's topics, including their full declaration and abstract. + case list + + /// A grid of items based on the card image for each page. + /// + /// Includes each page’s title and card image but excludes their abstracts. + case compactGrid + + /// A grid of items based on the card image for each page. + /// + /// Unlike ``compactGrid``, this style includes the abstract for each page. + case detailedGrid + + /// Do not show child pages anywhere on the page. + case hidden + } +} diff --git a/Sources/SwiftDocC/Semantics/Article/Article.swift b/Sources/SwiftDocC/Semantics/Article/Article.swift index e0e41c6898..a9888d2622 100644 --- a/Sources/SwiftDocC/Semantics/Article/Article.swift +++ b/Sources/SwiftDocC/Semantics/Article/Article.swift @@ -21,6 +21,8 @@ public final class Article: Semantic, MarkupConvertible, Abstracted, Redirected, let markup: Markup? /// An optional container for metadata that's unrelated to the article's content. private(set) var metadata: Metadata? + /// An optional container for options that are unrelated to the article's content. + private(set) var options: [Options.Scope : Options] /// An optional list of previously known locations for this article. private(set) public var redirects: [Redirect]? @@ -30,10 +32,11 @@ public final class Article: Semantic, MarkupConvertible, Abstracted, Redirected, /// - markup: The markup that makes up this article's content. /// - metadata: An optional container for metadata that's unrelated to the article's content. /// - redirects: An optional list of previously known locations for this article. - init(markup: Markup?, metadata: Metadata?, redirects: [Redirect]?) { + init(markup: Markup?, metadata: Metadata?, redirects: [Redirect]?, options: [Options.Scope : Options]) { let markupModel = markup.map { DocumentationMarkup(markup: $0) } self.markup = markup + self.options = options self.metadata = metadata self.redirects = redirects self.discussion = markupModel?.discussionSection @@ -46,7 +49,7 @@ public final class Article: Semantic, MarkupConvertible, Abstracted, Redirected, } convenience init(title: Heading?, abstractSection: AbstractSection?, discussion: DiscussionSection?, topics: TopicsSection?, seeAlso: SeeAlsoSection?, deprecationSummary: MarkupContainer?, metadata: Metadata?, redirects: [Redirect]?, automaticTaskGroups: [AutomaticTaskGroupSection]? = nil) { - self.init(markup: nil, metadata: metadata, redirects: redirects) + self.init(markup: nil, metadata: metadata, redirects: redirects, options: [:]) self.title = title self.abstractSection = abstractSection self.discussion = discussion @@ -139,7 +142,61 @@ public final class Article: Semantic, MarkupConvertible, Abstracted, Redirected, } var optionalMetadata = metadata.first + + let options: [Options] + (options, remainder) = remainder.categorize { child -> Options? in + guard let childDirective = child as? BlockDirective, childDirective.name == Options.directiveName else { + return nil + } + return Options( + from: childDirective, + source: source, + for: bundle, + in: context, + problems: &problems + ) + } + + let allCategorizedOptions = Dictionary(grouping: options, by: \.scope) + + for (scope, options) in allCategorizedOptions { + let extraOptions = options.dropFirst() + guard !extraOptions.isEmpty else { + continue + } + + let extraOptionsProblems = extraOptions.map { extraOptionsDirective -> Problem in + let diagnostic = Diagnostic( + source: source, + severity: .warning, + range: extraOptionsDirective.originalMarkup.range, + identifier: "org.swift.docc.HasAtMostOne<\(Article.self), \(Options.self), \(scope)>.DuplicateChildren", + summary: "Duplicate \(scope) \(Options.directiveName.singleQuoted) directive", + explanation: """ + An article can only contain a single \(Options.directiveName.singleQuoted) \ + directive with the \(scope.rawValue.singleQuoted) scope. + """ + ) + guard let range = extraOptionsDirective.originalMarkup.range else { + return Problem(diagnostic: diagnostic) + } + + let solution = Solution( + summary: "Remove extraneous \(scope) \(Options.directiveName.singleQuoted) directive", + replacements: [ + Replacement(range: range, replacement: "") + ] + ) + + return Problem(diagnostic: diagnostic, possibleSolutions: [solution]) + } + + problems.append(contentsOf: extraOptionsProblems) + } + + let relevantCategorizedOptions = allCategorizedOptions.compactMapValues(\.first) + let isDocumentationExtension = title.child(at: 0) is AnyLink if !isDocumentationExtension, let metadata = optionalMetadata, let displayName = metadata.displayName { let diagnosticSummary = """ @@ -164,7 +221,12 @@ public final class Article: Semantic, MarkupConvertible, Abstracted, Redirected, optionalMetadata = Metadata(originalMarkup: metadata.originalMarkup, documentationExtension: metadata.documentationOptions, technologyRoot: metadata.technologyRoot, displayName: nil) } - self.init(markup: markup, metadata: optionalMetadata, redirects: redirects.isEmpty ? nil : redirects) + self.init( + markup: markup, + metadata: optionalMetadata, + redirects: redirects.isEmpty ? nil : redirects, + options: relevantCategorizedOptions + ) } /// Visit the article using a semantic visitor and return the result of visiting the article. diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift index 568b916ee1..3444770e8d 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift @@ -17,6 +17,7 @@ struct DirectiveIndex { Snippet.self, DeprecationSummary.self, Row.self, + Options.self, ] private static let topLevelTutorialDirectives: [AutomaticDirectiveConvertible.Type] = [ diff --git a/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasArgumentOfType.swift b/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasArgumentOfType.swift index b02d9f93da..e1e4a75e80 100644 --- a/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasArgumentOfType.swift +++ b/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasArgumentOfType.swift @@ -73,15 +73,33 @@ extension Semantic.Analyses { let arguments = directive.arguments(problems: &problems) let source = directive.range?.lowerBound.source let diagnosticArgumentName = argumentName.isEmpty ? "unlabeled" : argumentName + guard let argument = arguments[argumentName] else { if let severity = severityIfNotFound { - let diagnostic = Diagnostic(source: source, severity: severity, range: directive.range, identifier: "org.swift.docc.HasArgument.\(diagnosticArgumentName)", summary: "\(Parent.directiveName) expects an argument \(argumentName.singleQuoted) that's convertible to \(valueTypeDiagnosticName.singleQuoted)") + let argumentDiagnosticDescription: String + if argumentName.isEmpty { + argumentDiagnosticDescription = "an unnamed parameter" + } else { + argumentDiagnosticDescription = "the \(argumentName.singleQuoted) parameter" + } + + let diagnostic = Diagnostic( + source: source, + severity: severity, + range: directive.range, + identifier: "org.swift.docc.HasArgument.\(diagnosticArgumentName)", + summary: "Missing argument for \(diagnosticArgumentName) parameter", + explanation: """ + \(Parent.directiveName) expects an argument for \(argumentDiagnosticDescription) \ + that's convertible to \(valueTypeDiagnosticName.singleQuoted) + """ + ) problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) } return nil } guard let value = convert(argument.value) else { - let diagnostic = Diagnostic(source: source, severity: .warning, range: argument.valueRange, identifier: "org.swift.docc.HasArgument.\(diagnosticArgumentName).ConversionFailed", summary: "Can't convert \(argument.value.singleQuoted) to type \(valueTypeDiagnosticName)") + let diagnostic = Diagnostic(source: source, severity: .warning, range: argument.valueRange, identifier: "org.swift.docc.HasArgument.\(diagnosticArgumentName).ConversionFailed", summary: "Cannot convert \(argument.value.singleQuoted) to type \(valueTypeDiagnosticName.singleQuoted)") let solutions = allowedValues.map { allowedValues -> [Solution] in return allowedValues.compactMap { allowedValue -> Solution? in guard let range = argument.valueRange else { diff --git a/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasOnlyKnownDirectives.swift b/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasOnlyKnownDirectives.swift index b40bf3878f..aca1b36dc7 100644 --- a/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasOnlyKnownDirectives.swift +++ b/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasOnlyKnownDirectives.swift @@ -48,7 +48,7 @@ extension Semantic.Analyses { if allowedDirectives.contains(childDirective.name) { summary = nil // This directive is allowed } else { - summary = "Block directive \(childDirective.name.singleQuoted) is unknown or invalid as a child of directive \(directive.name.singleQuoted)." + summary = "\(childDirective.name.singleQuoted) directive is unsupported as a child of the \(directive.name.singleQuoted) directive" } } else if !allowsMarkup { summary = "Arbitrary markup content is not allowed as a child of \(directive.name.singleQuoted)." @@ -58,7 +58,19 @@ extension Semantic.Analyses { if let summary = summary { let diagnostic = Diagnostic(source: source, severity: severity, range: child.range, identifier: "org.swift.docc.HasOnlyKnownDirectives", summary: summary, explanation: "These directives are allowed: \(allowedDirectivesList)") - problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) + + var solution: Solution? + if let childRange = child.range { + solution = Solution( + summary: "Remove unsupported child content", + replacements: [ + Replacement(range: childRange, replacement: "") + ] + ) + } + + + problems.append(Problem(diagnostic: diagnostic, possibleSolutions: solution.map { [$0] } ?? [])) } } } diff --git a/Sources/SwiftDocC/Semantics/Options/AutomaticSeeAlso.swift b/Sources/SwiftDocC/Semantics/Options/AutomaticSeeAlso.swift new file mode 100644 index 0000000000..6b635b6a7f --- /dev/null +++ b/Sources/SwiftDocC/Semantics/Options/AutomaticSeeAlso.swift @@ -0,0 +1,40 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2022 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import Markdown + +/// A directive for specifying Swift-DocC's automatic behavior when generating a page's +/// See Also section. +public class AutomaticSeeAlso: Semantic, AutomaticDirectiveConvertible { + public let originalMarkup: BlockDirective + + /// The specified behavior for automatic See Also section generation. + @DirectiveArgumentWrapped(name: .unnamed) + public private(set) var behavior: Behavior + + /// A behavior for automatic See Also section generation. + public enum Behavior: String, CaseIterable, DirectiveArgumentValueConvertible { + /// A See Also section will not be automatically created. + case disabled + + /// A See Also section will be automatically created based on the page's siblings. + case siblingPages + } + + static var keyPaths: [String : AnyKeyPath] = [ + "behavior" : \AutomaticSeeAlso._behavior, + ] + + @available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.") + required init(originalMarkup: BlockDirective) { + self.originalMarkup = originalMarkup + } +} diff --git a/Sources/SwiftDocC/Semantics/Options/AutomaticTitleHeading.swift b/Sources/SwiftDocC/Semantics/Options/AutomaticTitleHeading.swift new file mode 100644 index 0000000000..1cb589cd98 --- /dev/null +++ b/Sources/SwiftDocC/Semantics/Options/AutomaticTitleHeading.swift @@ -0,0 +1,42 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2022 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import Markdown + +/// A directive for specifying Swift-DocC's automatic behavior when generating a page's +/// title heading. +/// +/// A title heading is also known as a page eyebrow or kicker. +public class AutomaticTitleHeading: Semantic, AutomaticDirectiveConvertible { + public let originalMarkup: BlockDirective + + /// The specified behavior for automatic title heading generation. + @DirectiveArgumentWrapped(name: .unnamed) + public private(set) var behavior: Behavior + + /// A behavior for automatic title heading generation. + public enum Behavior: String, CaseIterable, DirectiveArgumentValueConvertible { + /// No title heading should be created for the page. + case disabled + + /// A title heading based on the page's kind should be automatically created. + case pageKind + } + + static var keyPaths: [String : AnyKeyPath] = [ + "behavior" : \AutomaticTitleHeading._behavior, + ] + + @available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.") + required init(originalMarkup: BlockDirective) { + self.originalMarkup = originalMarkup + } +} diff --git a/Sources/SwiftDocC/Semantics/Options/Options.swift b/Sources/SwiftDocC/Semantics/Options/Options.swift new file mode 100644 index 0000000000..b71ad4b19e --- /dev/null +++ b/Sources/SwiftDocC/Semantics/Options/Options.swift @@ -0,0 +1,67 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2022 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import Markdown + +/// A directive that specifies various options for the page. +public class Options: Semantic, AutomaticDirectiveConvertible { + public let originalMarkup: BlockDirective + + /// Whether the options in this Options directive should apply locally to the page + /// or globally to the DocC catalog. + @DirectiveArgumentWrapped + public private(set) var scope: Scope = .local + + @ChildDirective + public private(set) var _automaticSeeAlso: AutomaticSeeAlso? = nil + + @ChildDirective + public private(set) var _automaticTitleHeading: AutomaticTitleHeading? = nil + + @ChildDirective + public private(set) var _topicsVisualStyle: TopicsVisualStyle? = nil + + /// If given, the authored behavior for automatic See Also section generation. + public var automaticSeeAlsoBehavior: AutomaticSeeAlso.Behavior? { + return _automaticSeeAlso?.behavior + } + + /// If given, the authored behavior for automatic Title Heading generation. + public var automaticTitleHeadingBehavior: AutomaticTitleHeading.Behavior? { + return _automaticTitleHeading?.behavior + } + + /// If given, the authored style for a page's Topics section. + public var topicsVisualStyle: TopicsVisualStyle.Style? { + return _topicsVisualStyle?.style + } + + /// A scope for the options provided by an Options directive. + public enum Scope: String, CaseIterable, DirectiveArgumentValueConvertible { + /// The directive should only affect the current page. + case local + + /// The directive should affect all pages in the current DocC catalog. + case global + } + + static var keyPaths: [String : AnyKeyPath] = [ + "scope" : \Options._scope, + "_automaticSeeAlso" : \Options.__automaticSeeAlso, + "_automaticTitleHeading" : \Options.__automaticTitleHeading, + "_topicsVisualStyle" : \Options.__topicsVisualStyle, + ] + + @available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.") + required init(originalMarkup: BlockDirective) { + self.originalMarkup = originalMarkup + } +} diff --git a/Sources/SwiftDocC/Semantics/Options/TopicsVisualStyle.swift b/Sources/SwiftDocC/Semantics/Options/TopicsVisualStyle.swift new file mode 100644 index 0000000000..1ca7f75ac2 --- /dev/null +++ b/Sources/SwiftDocC/Semantics/Options/TopicsVisualStyle.swift @@ -0,0 +1,50 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2022 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import Markdown + +/// A directive for specifying the style that should be used when rendering a page's +/// Topics section. +public class TopicsVisualStyle: Semantic, AutomaticDirectiveConvertible { + public let originalMarkup: BlockDirective + + /// The specified style that should be used when rendering a page's Topics section. + @DirectiveArgumentWrapped(name: .unnamed) + public private(set) var style: Style + + /// A visual style for a page's Topics section. + public enum Style: String, CaseIterable, DirectiveArgumentValueConvertible { + /// A list of the page's topics, including their full declaration and abstract. + case list + + /// A grid of items based on the card image for each page. + /// + /// Includes each page’s title and card image but excludes their abstracts. + case compactGrid + + /// A grid of items based on the card image for each page. + /// + /// Unlike ``compactGrid``, this style includes the abstract for each page. + case detailedGrid + + /// Do not show child pages anywhere on the page. + case hidden + } + + static var keyPaths: [String : AnyKeyPath] = [ + "style" : \TopicsVisualStyle._style, + ] + + @available(*, deprecated, message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'.") + required init(originalMarkup: BlockDirective) { + self.originalMarkup = originalMarkup + } +} diff --git a/Sources/SwiftDocC/Semantics/SemanticAnalyzer.swift b/Sources/SwiftDocC/Semantics/SemanticAnalyzer.swift index 89112b41ff..209911ac31 100644 --- a/Sources/SwiftDocC/Semantics/SemanticAnalyzer.swift +++ b/Sources/SwiftDocC/Semantics/SemanticAnalyzer.swift @@ -155,6 +155,8 @@ struct SemanticAnalyzer: MarkupVisitor { // MarkupReferenceResolver. _ = Snippet(from: blockDirective, source: source, for: bundle, in: context, problems: &problems) return nil + case Options.directiveName: + return nil default: guard let directiveType = DirectiveIndex.shared.indexedDirectives[blockDirective.name]?.type else { let diagnostic = Diagnostic(source: source, severity: .warning, range: blockDirective.range, identifier: "org.swift.docc.unknownDirective", summary: "Unknown directive \(blockDirective.name.singleQuoted); this element will be ignored") diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 10b2283538..4ab89e6518 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -1841,6 +1841,16 @@ "$ref": "#/components/schemas/RenderInlineContent" } }, + "topicSectionsStyle": { + "type": "string", + "enum": [ + "list", + "compactGrid", + "detailedGrid", + "hidden" + ], + "default": "list" + }, "topicSections": { "type": "array", "items": { diff --git a/Tests/SwiftDocCTests/Converter/RenderNodeCodableTests.swift b/Tests/SwiftDocCTests/Converter/RenderNodeCodableTests.swift index cc7b0695b5..d1579fb566 100644 --- a/Tests/SwiftDocCTests/Converter/RenderNodeCodableTests.swift +++ b/Tests/SwiftDocCTests/Converter/RenderNodeCodableTests.swift @@ -10,6 +10,7 @@ import XCTest @testable import SwiftDocC +import Markdown class RenderNodeCodableTests: XCTestCase { @@ -158,6 +159,70 @@ class RenderNodeCodableTests: XCTestCase { } } + func testDecodeRenderNodeWithoutTopicSectionStyle() throws { + let exampleRenderNodeJSON = Bundle.module.url( + forResource: "Operator", + withExtension: "json", + subdirectory: "Test Resources" + )! + + let renderNodeData = try Data(contentsOf: exampleRenderNodeJSON) + + let renderNode = try JSONDecoder().decode(RenderNode.self, from: renderNodeData) + XCTAssertEqual(renderNode.topicSectionsStyle, .list) + } + + func testEncodeRenderNodeWithCustomTopicSectionStyle() throws { + let (bundle, context) = try testBundleAndContext() + var problems = [Problem]() + + let source = """ + # My Great Article + + A great article. + + @Options { + @TopicsVisualStyle(compactGrid) + } + """ + + let document = Document(parsing: source, options: .parseBlockDirectives) + let article = try XCTUnwrap( + Article(from: document.root, source: nil, for: bundle, in: context, problems: &problems) + ) + + let reference = ResolvedTopicReference( + bundleIdentifier: "org.swift.docc.example", + path: "/documentation/test/customTopicSectionStyle", + fragment: nil, + sourceLanguage: .swift + ) + context.documentationCache[reference] = try DocumentationNode(reference: reference, article: article) + let topicGraphNode = TopicGraph.Node( + reference: reference, + kind: .article, + source: .file(url: URL(fileURLWithPath: "/path/to/article.md")), + title: "My Article" + ) + context.topicGraph.addNode(topicGraphNode) + + var translator = RenderNodeTranslator( + context: context, + bundle: bundle, + identifier: reference, + source: nil + ) + let node = try XCTUnwrap(translator.visitArticle(article) as? RenderNode) + XCTAssertEqual(node.topicSectionsStyle, .compactGrid) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + + let encodedNode = try encoder.encode(node) + let decodedNode = try decoder.decode(RenderNode.self, from: encodedNode) + XCTAssertEqual(decodedNode.topicSectionsStyle, .compactGrid) + } + private func assertVariantOverrides(_ variantOverrides: VariantOverrides) throws { XCTAssertEqual(variantOverrides.values.count, 1) let variantOverride = try XCTUnwrap(variantOverrides.values.first) diff --git a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift index 46c305feab..9d321da7b3 100644 --- a/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/DocumentationContext/DocumentationContextTests.swift @@ -2308,7 +2308,7 @@ let expected = """ Article Abstract. """ // Assert we can create a documentation node from markup - let markupArticle = Article(markup: Document(parsing: source), metadata: nil, redirects: nil) + let markupArticle = Article(markup: Document(parsing: source), metadata: nil, redirects: nil, options: [:]) XCTAssertNoThrow(try DocumentationNode(reference: reference, article: markupArticle)) // Assert we cannot create new nodes from semantic article data diff --git a/Tests/SwiftDocCTests/Infrastructure/ReferenceResolverTests.swift b/Tests/SwiftDocCTests/Infrastructure/ReferenceResolverTests.swift index 6b601f2ec1..bdbc0a0334 100644 --- a/Tests/SwiftDocCTests/Infrastructure/ReferenceResolverTests.swift +++ b/Tests/SwiftDocCTests/Infrastructure/ReferenceResolverTests.swift @@ -406,7 +406,7 @@ class ReferenceResolverTests: XCTestCase { let (bundle, context) = try testBundleAndContext(named: "TestBundle") let document = Document(parsing: source, options: [.parseBlockDirectives, .parseSymbolLinks]) - let article = try XCTUnwrap(Article(markup: document, metadata: nil, redirects: nil)) + let article = try XCTUnwrap(Article(markup: document, metadata: nil, redirects: nil, options: [:])) var resolver = ReferenceResolver(context: context, bundle: bundle, source: nil) let resolvedArticle = try XCTUnwrap(resolver.visitArticle(article) as? Article) diff --git a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift index 23e1074b7a..8368b84c26 100644 --- a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift @@ -1782,6 +1782,32 @@ Document @1:1-11:19 """ ) } + + func testArticleRoleHeadingsWithAutomaticTitleHeadingDisabled() throws { + try assertRoleHeadingForArticleInTestBundle(expectedRoleHeading: nil, content: """ + # Article 2 + + @Options { + @AutomaticTitleHeading(disabled) + } + + This is article 2. + """ + ) + } + + func testArticleRoleHeadingsWithAutomaticTitleHeadingForPageKind() throws { + try assertRoleHeadingForArticleInTestBundle(expectedRoleHeading: "Article", content: """ + # Article 2 + + @Options { + @AutomaticTitleHeading(pageKind) + } + + This is article 2. + """ + ) + } func testAPICollectionRoleHeading() throws { try assertRoleHeadingForArticleInTestBundle(expectedRoleHeading: nil, content: """ diff --git a/Tests/SwiftDocCTests/Rendering/AutomaticSeeAlsoTests.swift b/Tests/SwiftDocCTests/Rendering/AutomaticSeeAlsoTests.swift index e64f213d83..6fe3fbbdcd 100644 --- a/Tests/SwiftDocCTests/Rendering/AutomaticSeeAlsoTests.swift +++ b/Tests/SwiftDocCTests/Rendering/AutomaticSeeAlsoTests.swift @@ -133,5 +133,127 @@ class AutomaticSeeAlsoTests: XCTestCase { XCTAssertEqual(renderNode.seeAlsoSections[0].generated, true) } } + + // Duplicate of the `testAuthoredAndAutomaticSeeAlso()` test above + // but with automatic see also creation disabled + func testAuthoredSeeAlsoWithDisabledAutomaticSeeAlso() throws { + let (_, bundle, context) = try testBundleAndContext(copying: "TestBundle") { root in + /// Article that curates `SideClass` + try """ + # ``SideKit`` + SideKit module root symbol + ## Topics + ### Basics + - ``SideClass`` + - + """.write(to: root.appendingPathComponent("documentation/sidekit.md"), atomically: true, encoding: .utf8) + + /// Authored See Also + try """ + # ``SideKit/SideClass`` + SideClass abstract. + + @Options { + @AutomaticSeeAlso(disabled) + } + + ## See Also + - ``SideKit`` + """.write(to: root.appendingPathComponent("documentation/sideclass.md"), atomically: true, encoding: .utf8) + + /// Article Sibling + try """ + # Side Article + + Side Article abstract. + """.write(to: root.appendingPathComponent("documentation/sidearticle.md"), atomically: true, encoding: .utf8) + } + + // Get a translated render node + let node = try context.entity(with: ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/documentation/SideKit/SideClass", sourceLanguage: .swift)) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference, source: nil) + let renderNode = translator.visit(node.semantic as! Symbol) as! RenderNode + + // Verify there is an authored See Also but no automatically created See Also + XCTAssertEqual(renderNode.seeAlsoSections.count, 1) + guard renderNode.seeAlsoSections.count == 1 else { return } + + XCTAssertEqual(renderNode.seeAlsoSections[0].title, "Related Documentation") + XCTAssertEqual(renderNode.seeAlsoSections[0].identifiers, ["doc://org.swift.docc.example/documentation/SideKit"]) + + // Verify that article without options directive still gets automatic See Also sections + do { + let node = try context.entity(with: ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/documentation/Test-Bundle/sidearticle", sourceLanguage: .swift)) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference, source: nil) + let renderNode = translator.visit(node.semantic as! Article) as! RenderNode + + // Verify there is an automacially created See Also + XCTAssertEqual(renderNode.seeAlsoSections.count, 1) + guard renderNode.seeAlsoSections.count == 1 else { return } + + XCTAssertEqual(renderNode.seeAlsoSections[0].title, "Basics") + XCTAssertEqual(renderNode.seeAlsoSections[0].identifiers, ["doc://org.swift.docc.example/documentation/SideKit/SideClass"]) + XCTAssertEqual(renderNode.seeAlsoSections[0].generated, true) + } + } + + // Duplicate of the `testAuthoredAndAutomaticSeeAlso()` test above + // but with automatic see also creation globally disabled + func testAuthoredSeeAlsoWithGloballyDisabledAutomaticSeeAlso() throws { + let (_, bundle, context) = try testBundleAndContext(copying: "TestBundle") { root in + /// Article that curates `SideClass` + try """ + # ``SideKit`` + SideKit module root symbol + + @Options(scope: global) { + @AutomaticSeeAlso(disabled) + } + + ## Topics + ### Basics + - ``SideClass`` + - + """.write(to: root.appendingPathComponent("documentation/sidekit.md"), atomically: true, encoding: .utf8) + + /// Authored See Also + try """ + # ``SideKit/SideClass`` + SideClass abstract. + + ## See Also + - ``SideKit`` + """.write(to: root.appendingPathComponent("documentation/sideclass.md"), atomically: true, encoding: .utf8) + + /// Article Sibling + try """ + # Side Article + + Side Article abstract. + """.write(to: root.appendingPathComponent("documentation/sidearticle.md"), atomically: true, encoding: .utf8) + } + + // Get a translated render node + let node = try context.entity(with: ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/documentation/SideKit/SideClass", sourceLanguage: .swift)) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference, source: nil) + let renderNode = translator.visit(node.semantic as! Symbol) as! RenderNode + + // Verify there is an authored See Also but no automatically created See Also + XCTAssertEqual(renderNode.seeAlsoSections.count, 1) + guard renderNode.seeAlsoSections.count == 1 else { return } + + XCTAssertEqual(renderNode.seeAlsoSections[0].title, "Related Documentation") + XCTAssertEqual(renderNode.seeAlsoSections[0].identifiers, ["doc://org.swift.docc.example/documentation/SideKit"]) + + // Verify that article without options directive still gets automatic See Also sections + do { + let node = try context.entity(with: ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/documentation/Test-Bundle/sidearticle", sourceLanguage: .swift)) + var translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: node.reference, source: nil) + let renderNode = translator.visit(node.semantic as! Article) as! RenderNode + + // Verify there is an automacially created See Also + XCTAssertTrue(renderNode.seeAlsoSections.isEmpty) + } + } } diff --git a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift index e5b5ec7ca2..257bf2326b 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift @@ -208,6 +208,7 @@ class RenderNodeTranslatorTests: XCTestCase { func testArticleRoles() throws { let (bundle, context) = try testBundleAndContext(named: "TestBundle") + var problems = [Problem]() // Verify article's role do { @@ -218,7 +219,9 @@ class RenderNodeTranslatorTests: XCTestCase { My conclusion. """ let document = Document(parsing: source, options: .parseBlockDirectives) - let article = Article(markup: document.root, metadata: nil, redirects: nil) + let article = try XCTUnwrap( + Article(from: document.root, source: nil, for: bundle, in: context, problems: &problems) + ) let translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/article", fragment: nil, sourceLanguage: .swift), source: nil) XCTAssertEqual(RenderMetadata.Role.article, translator.contentRenderer.roleForArticle(article, nodeKind: .article)) @@ -239,7 +242,9 @@ class RenderNodeTranslatorTests: XCTestCase { let translator = RenderNodeTranslator(context: context, bundle: bundle, identifier: ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/article", fragment: nil, sourceLanguage: .swift), source: nil) // Verify a collection group - let article1 = Article(markup: document.root, metadata: nil, redirects: nil) + let article1 = try XCTUnwrap( + Article(from: document.root, source: nil, for: bundle, in: context, problems: &problems) + ) XCTAssertEqual(RenderMetadata.Role.collectionGroup, translator.contentRenderer.roleForArticle(article1, nodeKind: .article)) let metadataSource = """ @@ -247,13 +252,15 @@ class RenderNodeTranslatorTests: XCTestCase { @TechnologyRoot } """ - let metadataDocument = Document(parsing: metadataSource, options: .parseBlockDirectives) - let directive = metadataDocument.child(at: 0) as! BlockDirective - var problems = [Problem]() - let metadata = Metadata(from: directive, source: nil, for: bundle, in: context, problems: &problems) + let metadataDocument = Document( + parsing: source + "\n" + metadataSource, + options: .parseBlockDirectives + ) // Verify a collection - let article2 = Article(markup: document.root, metadata: metadata, redirects: nil) + let article2 = try XCTUnwrap( + Article(from: metadataDocument.root, source: nil, for: bundle, in: context, problems: &problems) + ) XCTAssertEqual(RenderMetadata.Role.collection, translator.contentRenderer.roleForArticle(article2, nodeKind: .article)) } } @@ -284,6 +291,7 @@ class RenderNodeTranslatorTests: XCTestCase { func testEmtpyTaskGroupsNotRendered() throws { let (bundle, context) = try testBundleAndContext(named: "TestBundle") + var problems = [Problem]() let source = """ # My Article @@ -317,7 +325,9 @@ class RenderNodeTranslatorTests: XCTestCase { """ let document = Document(parsing: source, options: .parseBlockDirectives) - let article = Article(markup: document.root, metadata: nil, redirects: nil) + let article = try XCTUnwrap( + Article(from: document.root, source: nil, for: bundle, in: context, problems: &problems) + ) let reference = ResolvedTopicReference(bundleIdentifier: "org.swift.docc.example", path: "/documentation/Test-Bundle/taskgroups", fragment: nil, sourceLanguage: .swift) context.documentationCache[reference] = try DocumentationNode(reference: reference, article: article) let topicGraphNode = TopicGraph.Node(reference: reference, kind: .article, source: .file(url: URL(fileURLWithPath: "/path/to/article.md")), title: "My Article") diff --git a/Tests/SwiftDocCTests/Semantics/ArticleTests.swift b/Tests/SwiftDocCTests/Semantics/ArticleTests.swift index a923120430..6c7e33fd13 100644 --- a/Tests/SwiftDocCTests/Semantics/ArticleTests.swift +++ b/Tests/SwiftDocCTests/Semantics/ArticleTests.swift @@ -167,4 +167,45 @@ class ArticleTests: XCTestCase { let replacement = try XCTUnwrap(solution.replacements.first) XCTAssertEqual(replacement.replacement, "# <#Title#>") } + + func testArticleWithDuplicateOptions() throws { + let source = """ + # Article + + @Options { + @AutomaticSeeAlso(disabled) + } + + This is an abstract. + + @Options { + @AutomaticSeeAlso(siblingPages) + } + + Here's an overview. + """ + let document = Document(parsing: source, options: [.parseBlockDirectives]) + let (bundle, context) = try testBundleAndContext(named: "TestBundle") + var problems = [Problem]() + let article = Article(from: document, source: nil, for: bundle, in: context, problems: &problems) + XCTAssertNotNil(article) + XCTAssertEqual( + problems.map(\.diagnostic.identifier), + [ + "org.swift.docc.HasAtMostOne.DuplicateChildren", + ] + ) + + XCTAssertEqual(problems.count, 1) + XCTAssertEqual( + problems.first?.diagnostic.identifier, + "org.swift.docc.HasAtMostOne.DuplicateChildren" + ) + XCTAssertEqual( + problems.first?.diagnostic.range?.lowerBound.line, + 9 + ) + + XCTAssertEqual(article?.options[.local]?.automaticSeeAlsoBehavior, .disabled) + } } diff --git a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift index fb93e76375..876e7af84a 100644 --- a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift +++ b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift @@ -18,6 +18,8 @@ class DirectiveIndexTests: XCTestCase { DirectiveIndex.shared.indexedDirectives.keys.sorted(), [ "Assessments", + "AutomaticSeeAlso", + "AutomaticTitleHeading", "Chapter", "Choice", "Column", @@ -28,11 +30,13 @@ class DirectiveIndexTests: XCTestCase { "Intro", "Justification", "Metadata", + "Options", "Redirected", "Row", "Snippet", "Stack", "TechnologyRoot", + "TopicsVisualStyle", "Tutorial", "TutorialReference", "Video", diff --git a/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownDirectivesTests.swift b/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownDirectivesTests.swift index c8620f183c..3412fa0c15 100644 --- a/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownDirectivesTests.swift +++ b/Tests/SwiftDocCTests/Semantics/General Purpose Analyses/HasOnlyKnownDirectivesTests.swift @@ -121,7 +121,7 @@ class HasOnlyKnownDirectivesTests: XCTestCase { XCTAssertEqual(problems.count, 1) guard let first = problems.first else { return } - XCTAssertEqual("error: Block directive 'baz' is unknown or invalid as a child of directive 'dir'.\nThese directives are allowed: 'Comment', 'bar', 'bark', 'foo', 'woof'", first.diagnostic.localizedDescription) + XCTAssertEqual("error: 'baz' directive is unsupported as a child of the 'dir' directive\nThese directives are allowed: 'Comment', 'bar', 'bark', 'foo', 'woof'", first.diagnostic.localizedDescription) } } diff --git a/Tests/SwiftDocCTests/Semantics/Options/OptionsTests.swift b/Tests/SwiftDocCTests/Semantics/Options/OptionsTests.swift new file mode 100644 index 0000000000..6a60360c22 --- /dev/null +++ b/Tests/SwiftDocCTests/Semantics/Options/OptionsTests.swift @@ -0,0 +1,295 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2022 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation + +import XCTest +@testable import SwiftDocC +import Markdown + +class OptionsTests: XCTestCase { + func testDefaultOptions() throws { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + + } + """ + } + + XCTAssertTrue(problems.isEmpty) + let unwrappedOptions = try XCTUnwrap(options) + + XCTAssertNil(unwrappedOptions.automaticTitleHeadingBehavior) + XCTAssertNil(unwrappedOptions.automaticSeeAlsoBehavior) + XCTAssertNil(unwrappedOptions.topicsVisualStyle) + XCTAssertEqual(unwrappedOptions.scope, .local) + } + + func testOptionsParameters() throws { + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options(scope: global) { + + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.scope, .global) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options(scope: local) { + + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.scope, .local) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options(scope: global, random: foo) { + + } + """ + } + + XCTAssertEqual(options?.scope, .global) + XCTAssertEqual( + problems, + [ + "1: warning – org.swift.docc.UnknownArgument", + ] + ) + } + } + + func testAutomaticSeeAlso() throws { + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticSeeAlso(disabled) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.automaticSeeAlsoBehavior, .disabled) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticSeeAlso(siblingPages) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.automaticSeeAlsoBehavior, .siblingPages) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticSeeAlso(foo) + } + """ + } + + + XCTAssertNotNil(options) + XCTAssertNil(options?.automaticSeeAlsoBehavior) + + XCTAssertEqual( + problems, + [ + "2: warning – org.swift.docc.HasArgument.unlabeled.ConversionFailed", + ] + ) + } + } + + func testTopicsVisualStyle() throws { + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @TopicsVisualStyle(detailedGrid) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.topicsVisualStyle, .detailedGrid) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @TopicsVisualStyle(compactGrid) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.topicsVisualStyle, .compactGrid) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @TopicsVisualStyle(list) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.topicsVisualStyle, .list) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @TopicsVisualStyle(hidden) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.topicsVisualStyle, .hidden) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticSeeAlso(foo) + } + """ + } + + + XCTAssertNotNil(options) + XCTAssertNil(options?.topicsVisualStyle) + + XCTAssertEqual( + problems, + [ + "2: warning – org.swift.docc.HasArgument.unlabeled.ConversionFailed", + ] + ) + } + } + + func testAutomaticTitleHeading() throws { + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticTitleHeading(disabled) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.automaticTitleHeadingBehavior, .disabled) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticTitleHeading(pageKind) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.automaticTitleHeadingBehavior, .pageKind) + } + + do { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticTitleHeading(foo) + } + """ + } + + + XCTAssertNotNil(options) + XCTAssertNil(options?.automaticTitleHeadingBehavior) + + XCTAssertEqual( + problems, + [ + "2: warning – org.swift.docc.HasArgument.unlabeled.ConversionFailed", + ] + ) + } + } + + func testMixOfOptions() throws { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticTitleHeading(pageKind) + @AutomaticSeeAlso(disabled) + @TopicsVisualStyle(detailedGrid) + } + """ + } + + XCTAssertTrue(problems.isEmpty) + XCTAssertEqual(options?.automaticTitleHeadingBehavior, .pageKind) + XCTAssertEqual(options?.automaticSeeAlsoBehavior, .disabled) + XCTAssertEqual(options?.topicsVisualStyle, .detailedGrid) + } + + func testUnsupportedChild() throws { + let (problems, options) = try parseDirective(Options.self) { + """ + @Options { + @AutomaticTitleHeading(pageKind) + @Row { + @Column { + Hi! + } + } + } + """ + } + + XCTAssertEqual(options?.automaticTitleHeadingBehavior, .pageKind) + XCTAssertEqual( + problems, + [ + "1: warning – org.swift.docc.Options.UnexpectedContent", + "3: warning – org.swift.docc.HasOnlyKnownDirectives", + ] + ) + } +} diff --git a/Tests/SwiftDocCTests/Semantics/RedirectedTests.swift b/Tests/SwiftDocCTests/Semantics/RedirectedTests.swift index 429795f9b2..92ea9408f3 100644 --- a/Tests/SwiftDocCTests/Semantics/RedirectedTests.swift +++ b/Tests/SwiftDocCTests/Semantics/RedirectedTests.swift @@ -59,7 +59,7 @@ class RedirectedTests: XCTestCase { XCTAssertEqual(problems.first?.diagnostic.identifier, "org.swift.docc.HasArgument.from.ConversionFailed") XCTAssertEqual( problems.first?.diagnostic.localizedSummary, - "Can't convert '\(pathWithInvalidCharacter)' to type URL" + "Cannot convert '\(pathWithInvalidCharacter)' to type 'URL'" ) } } diff --git a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift index 52d541a2d2..65108a3a09 100644 --- a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift +++ b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift @@ -77,10 +77,7 @@ extension XCTestCase { return bundles[0] } - func parseDirective( - _ directive: Directive.Type, - source: () -> String - ) throws -> (renderBlockContent: RenderBlockContent?, problemIdentifiers: [String], directive: Directive?) { + func testBundleAndContext() throws -> (bundle: DocumentationBundle, context: DocumentationContext) { let bundle = DocumentationBundle( info: DocumentationBundle.Info( displayName: "Test", @@ -97,6 +94,43 @@ extension XCTestCase { try workspace.registerProvider(provider) let context = try DocumentationContext(dataProvider: workspace) + return (bundle, context) + } + + func parseDirective( + _ directive: Directive.Type, + source: () -> String + ) throws -> (problemIdentifiers: [String], directive: Directive?) { + let (bundle, context) = try testBundleAndContext() + + let document = Document(parsing: source(), options: .parseBlockDirectives) + + let blockDirectiveContainer = try XCTUnwrap(document.child(at: 0) as? BlockDirective) + + var problems = [Problem]() + let directive = directive.init( + from: blockDirectiveContainer, + source: nil, + for: bundle, + in: context, + problems: &problems + ) + + let problemIDs = problems.map { problem -> String in + let line = problem.diagnostic.range?.lowerBound.line.description ?? "unknown-line" + + return "\(line): \(problem.diagnostic.severity) – \(problem.diagnostic.identifier)" + }.sorted() + + return (problemIDs, directive) + } + + func parseDirective( + _ directive: Directive.Type, + source: () -> String + ) throws -> (renderBlockContent: RenderBlockContent?, problemIdentifiers: [String], directive: Directive?) { + let (bundle, context) = try testBundleAndContext() + let document = Document(parsing: source(), options: .parseBlockDirectives) let blockDirectiveContainer = try XCTUnwrap(document.child(at: 0) as? BlockDirective)