diff --git a/Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift b/Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift index 9bd7c8e1f1..03ee1f81b7 100644 --- a/Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift +++ b/Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift @@ -58,6 +58,10 @@ extension RenderBlockContent: TextIndexing { return $0.term.inlineContent.rawIndexableTextContent(references: references) + ( definition.isEmpty ? "" : " \(definition)" ) }.joined(separator: " ") + case .row(let row): + return row.columns.map { column in + return column.content.rawIndexableTextContent(references: references) + }.joined(separator: " ") default: fatalError("unknown RenderBlockContent case in rawIndexableTextContent") } diff --git a/Sources/SwiftDocC/Model/DocumentationMarkup.swift b/Sources/SwiftDocC/Model/DocumentationMarkup.swift index 9ea16abf21..1d51af8e05 100644 --- a/Sources/SwiftDocC/Model/DocumentationMarkup.swift +++ b/Sources/SwiftDocC/Model/DocumentationMarkup.swift @@ -142,12 +142,17 @@ struct DocumentationMarkup { abstractSection = AbstractSection(paragraph: firstParagraph) return } else if let directive = child as? BlockDirective { - // Found deprecation notice in the abstract. if directive.name == DeprecationSummary.directiveName { + // Found deprecation notice in the abstract. deprecation = MarkupContainer(directive.children) + return + } else if directive.name == Comment.directiveName || directive.name == Metadata.directiveName { + // These directives don't affect content so they shouldn't break us out of + // the automatic abstract section. + return + } else { + currentSection = .discussion } - // Skip other block like @Comment and so on. - return } else if let _ = child as? HTMLBlock { // Skip HTMLBlock comment. return diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 9ee29f427d..58cac267c6 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -60,6 +60,8 @@ public enum RenderBlockContent: Equatable { case termList(TermList) /// A table that contains a list of row data. case table(Table) + + case row(Row) // Warning: If you add a new case to this enum, make sure to handle it in the Codable // conformance at the bottom of this file, and in the `rawIndexableTextContent` method in @@ -403,6 +405,28 @@ public enum RenderBlockContent: Equatable { /// The definition in the term-list item. public let definition: Definition } + + /// A row in a grid-based layout system that describes a collection of columns. + public struct Row: Codable, Equatable { + /// The number of columns that should be rendered in this row. + /// + /// This may be different then the count of ``columns`` array. For example, there may be + /// individual columns that span multiple columns (specified with the column's + /// ``Column/size`` property) or the row could be not fully filled with columns. + public let numberOfColumns: Int + + /// The columns that should be rendered in this row. + public let columns: [Column] + + /// A column with a row in a grid-based layout system. + public struct Column: Codable, Equatable { + /// The number of columns in the parent row this column should span. + public let size: Int + + /// The content that should be rendered in this column. + public let content: [RenderBlockContent] + } + } } // Codable conformance @@ -412,6 +436,7 @@ extension RenderBlockContent: Codable { case inlineContent, content, caption, style, name, syntax, code, level, text, items, media, runtimePreview, anchor, summary, example, metadata case request, response case header, rows + case numberOfColumns, columns } public init(from decoder: Decoder) throws { @@ -457,11 +482,18 @@ extension RenderBlockContent: Codable { )) case .termList: self = try .termList(.init(items: container.decode([TermListItem].self, forKey: .items))) + case .row: + self = try .row( + Row( + numberOfColumns: container.decode(Int.self, forKey: .numberOfColumns), + columns: container.decode([Row.Column].self, forKey: .columns) + ) + ) } } private enum BlockType: String, Codable { - case paragraph, aside, codeListing, heading, orderedList, unorderedList, step, endpointExample, dictionaryExample, table, termList + case paragraph, aside, codeListing, heading, orderedList, unorderedList, step, endpointExample, dictionaryExample, table, termList, row } private var type: BlockType { @@ -477,6 +509,7 @@ extension RenderBlockContent: Codable { case .dictionaryExample: return .dictionaryExample case .table: return .table case .termList: return .termList + case .row: return .row default: fatalError("unknown RenderBlockContent case in type property") } } @@ -523,6 +556,9 @@ extension RenderBlockContent: Codable { try container.encodeIfPresent(t.metadata, forKey: .metadata) case .termList(items: let l): try container.encode(l.items, forKey: .items) + case .row(let row): + try container.encode(row.numberOfColumns, forKey: .numberOfColumns) + try container.encode(row.columns, forKey: .columns) default: fatalError("unknown RenderBlockContent case in encode method") } diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index e1ca6a039f..a61cba25e1 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -237,7 +237,11 @@ struct RenderContentCompiler: MarkupVisitor { return docCommentContent + [code] } default: - return [] + guard let renderableDirective = DirectiveIndex.shared.renderableDirectives[blockDirective.name] else { + return [] + } + + return renderableDirective.render(blockDirective, with: &self) } } diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentConvertible.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentConvertible.swift new file mode 100644 index 0000000000..977427c171 --- /dev/null +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentConvertible.swift @@ -0,0 +1,52 @@ +/* + 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 can be directly rendered within markup content. +/// +/// This protocol is used by the `RenderContentCompiler` to render arbitrary directives +/// that conform to renderable. +protocol RenderableDirectiveConvertible: AutomaticDirectiveConvertible { + func render(with contentCompiler: inout RenderContentCompiler) -> [RenderContent] +} + +extension RenderableDirectiveConvertible { + static func render( + _ blockDirective: BlockDirective, + with contentCompiler: inout RenderContentCompiler + ) -> [RenderContent] { + guard let directive = Self.init( + from: blockDirective, + for: contentCompiler.bundle, + in: contentCompiler.context + ) else { + return [] + } + + return directive.render(with: &contentCompiler) + } +} + +struct AnyRenderableDirectiveConvertibleType { + var underlyingType: RenderableDirectiveConvertible.Type + + func render( + _ blockDirective: BlockDirective, + with contentCompiler: inout RenderContentCompiler + ) -> [RenderContent] { + return underlyingType.render(blockDirective, with: &contentCompiler) + } + + var directiveName: String { + return underlyingType.directiveName + } +} diff --git a/Sources/SwiftDocC/Model/Rendering/RenderSection.swift b/Sources/SwiftDocC/Model/Rendering/RenderSection.swift index f67a6a0b77..0506911b70 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderSection.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderSection.swift @@ -17,7 +17,7 @@ public enum RenderSectionKind: String, Codable { case hero, intro, tasks, assessments, volume, contentAndMedia, contentAndMediaGroup, callToAction, tile, articleBody, resources // Symbol render sections - case discussion, content, taskGroup, relationships, declarations, parameters, sampleDownload + case discussion, content, taskGroup, relationships, declarations, parameters, sampleDownload, row // Rest symbol sections case restParameters, restResponses, restBody, restEndpoint, properties diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/AutomaticDirectiveConvertible.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/AutomaticDirectiveConvertible.swift index 1ed544efe4..76c60a2f73 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/AutomaticDirectiveConvertible.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/AutomaticDirectiveConvertible.swift @@ -168,7 +168,8 @@ extension AutomaticDirectiveConvertible { Semantic.Analyses.HasOnlyKnownDirectives( severityIfFound: .warning, - allowedDirectives: reflectedDirective.childDirectives.map(\.name) + allowedDirectives: reflectedDirective.childDirectives.map(\.name), + allowsStructuredMarkup: reflectedDirective.allowsStructuredMarkup ) .analyze( directive, diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/ChildMarkdownWrapper.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/ChildMarkdownWrapper.swift index 3a48ac5d68..1c54e32c51 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/ChildMarkdownWrapper.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/ChildMarkdownWrapper.swift @@ -16,6 +16,8 @@ protocol _ChildMarkupProtocol { var index: Int? { get } + var supportsStructuredMarkup: Bool { get } + func setProperty( on containingDirective: T, named propertyName: String, @@ -61,6 +63,10 @@ public struct ChildMarkup: _ChildMarkupProtocol { var numberOfParagraphs: _ChildMarkupParagraphs + /// Returns true if the child markup can contain structured markup content like + /// rows and columns. + var supportsStructuredMarkup: Bool + public var wrappedValue: Value { get { parsedValue! @@ -89,10 +95,15 @@ public struct ChildMarkup: _ChildMarkupProtocol { } extension ChildMarkup where Value == MarkupContainer { - init(numberOfParagraphs: _ChildMarkupParagraphs = .oneOrMore, index: Int? = nil) { + init( + numberOfParagraphs: _ChildMarkupParagraphs = .oneOrMore, + index: Int? = nil, + supportsStructure: Bool = false + ) { self.parsedValue = MarkupContainer() self.numberOfParagraphs = numberOfParagraphs self.index = index + self.supportsStructuredMarkup = supportsStructure } } @@ -100,10 +111,12 @@ extension ChildMarkup where Value == Optional { init( value: Value, numberOfParagraphs: _ChildMarkupParagraphs = .zeroOrMore, - index: Int? = nil + index: Int? = nil, + supportsStructure: Bool = false ) { self.parsedValue = value self.numberOfParagraphs = numberOfParagraphs self.index = index + self.supportsStructuredMarkup = supportsStructure } } diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift index 64cc6b72ea..568b916ee1 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift @@ -16,6 +16,7 @@ struct DirectiveIndex { Redirect.self, Snippet.self, DeprecationSummary.self, + Row.self, ] private static let topLevelTutorialDirectives: [AutomaticDirectiveConvertible.Type] = [ @@ -39,6 +40,8 @@ struct DirectiveIndex { let indexedDirectives: [String : DirectiveMirror.ReflectedDirective] + let renderableDirectives: [String : AnyRenderableDirectiveConvertibleType] + static let shared = DirectiveIndex() private init() { @@ -78,6 +81,14 @@ struct DirectiveIndex { } self.indexedDirectives = indexedDirectives + + self.renderableDirectives = indexedDirectives.compactMapValues { directive in + guard let renderableDirective = directive.type as? RenderableDirectiveConvertible.Type else { + return nil + } + + return AnyRenderableDirectiveConvertibleType(underlyingType: renderableDirective) + } } func reflection( diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveMirror.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveMirror.swift index bd72b22ce8..747cd8c192 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveMirror.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveMirror.swift @@ -240,6 +240,15 @@ extension DirectiveMirror { } } + var allowsStructuredMarkup: Bool { + switch childMarkupSupport { + case .supportsMarkup(let markupRequirements): + return markupRequirements.first?.markup.supportsStructuredMarkup ?? false + case .disallowsMarkup: + return false + } + } + var requiresMarkup: Bool { switch childMarkupSupport { case .supportsMarkup(let markupRequirements): diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/MarkupContaining.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/MarkupContaining.swift new file mode 100644 index 0000000000..464d071953 --- /dev/null +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/MarkupContaining.swift @@ -0,0 +1,20 @@ +/* + 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 Markdown + +/// A directive convertible that contains markup. +protocol MarkupContaining: DirectiveConvertible { + /// The markup contained by this directive. + /// + /// This property does not necessarily return the markup contained only by this directive, it may + /// be the concatenated markup contained by all of this directive's directive children. + var childMarkup: [Markup] { get } +} diff --git a/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasOnlyKnownDirectives.swift b/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasOnlyKnownDirectives.swift index b73864dcf6..b40bf3878f 100644 --- a/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasOnlyKnownDirectives.swift +++ b/Sources/SwiftDocC/Semantics/General Purpose Analyses/HasOnlyKnownDirectives.swift @@ -17,11 +17,24 @@ extension Semantic.Analyses { let severityIfFound: DiagnosticSeverity? let allowedDirectives: [String] let allowsMarkup: Bool - public init(severityIfFound: DiagnosticSeverity?, allowedDirectives: [String], allowsMarkup: Bool = true) { + public init( + severityIfFound: DiagnosticSeverity?, + allowedDirectives: [String], + allowsMarkup: Bool = true, + allowsStructuredMarkup: Bool = false + ) { self.severityIfFound = severityIfFound - self.allowedDirectives = allowedDirectives + var allowedDirectives = allowedDirectives /* Comments are always allowed because they are ignored. */ + [Comment.directiveName] + + if allowsStructuredMarkup { + allowedDirectives += DirectiveIndex.shared.renderableDirectives.values.map { + return $0.directiveName + } + } + self.allowedDirectives = allowedDirectives + self.allowsMarkup = allowsMarkup } diff --git a/Sources/SwiftDocC/Semantics/Reference/Row.swift b/Sources/SwiftDocC/Semantics/Reference/Row.swift new file mode 100644 index 0000000000..de60b8f502 --- /dev/null +++ b/Sources/SwiftDocC/Semantics/Reference/Row.swift @@ -0,0 +1,143 @@ +/* + 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 container directive that arranges content into a grid-based row and column +/// layout. +/// +/// Create a new row by creating an `@Row` that contains child `@Column` directives. +/// +/// ```md +/// @Row { +/// @Column { +/// @Image(source: "icon-power-icon", alt: "A blue square containing a snowflake.") { +/// Ice power +/// } +/// } +/// +/// @Column { +/// @Image(source: "fire-power-icon", alt: "A red square containing a flame.") { +/// Fire power +/// } +/// } +/// +/// @Column { +/// @Image(source: "wind-power-icon", alt: "A teal square containing a breath of air.") { +/// Wind power +/// } +/// } +/// +/// @Column { +/// @Image(source: "lightning-power-icon", alt: "A yellow square containing a lightning bolt.") { +/// Lightning power +/// } +/// } +/// } +/// ``` +public final class Row: Semantic, AutomaticDirectiveConvertible, MarkupContaining { + public let originalMarkup: BlockDirective + + @DirectiveArgumentWrapped(name: .custom("numberOfColumns")) + public private(set) var _numberOfColumns: Int? = nil + + /// The columns that make up this row. + @ChildDirective(requirements: .oneOrMore) + public private(set) var columns: [Column] + + static var keyPaths: [String : AnyKeyPath] = [ + "_numberOfColumns" : \Row.__numberOfColumns, + "columns" : \Row._columns, + ] + + /// The number of columns in this row. + /// + /// This may be different then the count of ``columns`` array. For example, there may be + /// individual columns that span multiple columns (specified with the column's + /// ``Column/size`` argument) or the row could be not fully filled with columns. + public var numberOfColumns: Int { + return _numberOfColumns ?? columns.map(\.size).reduce(0, +) + } + + override var children: [Semantic] { + return columns + } + + var childMarkup: [Markup] { + return columns.flatMap(\.childMarkup) + } + + @available(*, deprecated, + message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'." + ) + init(originalMarkup: BlockDirective) { + self.originalMarkup = originalMarkup + } +} + +extension Row { + /// A container directive that holds general markup content describing a column + /// with a row in a grid-based layout. + public final class Column: Semantic, AutomaticDirectiveConvertible, MarkupContaining { + public let originalMarkup: BlockDirective + + /// The size of this column. + /// + /// Specify a value greater than `1` to make this column span multiple columns + /// in the parent ``Row``. + @DirectiveArgumentWrapped + public private(set) var size: Int = 1 + + /// The markup content in this column. + @ChildMarkup(numberOfParagraphs: .zeroOrMore, supportsStructure: true) + public private(set) var content: MarkupContainer + + static var keyPaths: [String : AnyKeyPath] = [ + "size" : \Column._size, + "content" : \Column._content, + ] + + override var children: [Semantic] { + return [content] + } + + var childMarkup: [Markup] { + return content.elements + } + + @available(*, deprecated, + message: "Do not call directly. Required for 'AutomaticDirectiveConvertible'." + ) + init(originalMarkup: BlockDirective) { + self.originalMarkup = originalMarkup + } + } +} + +extension Row: RenderableDirectiveConvertible { + func render(with contentCompiler: inout RenderContentCompiler) -> [RenderContent] { + let renderedColumns = columns.map { column in + return RenderBlockContent.Row.Column( + size: column.size, + content: column.content.elements.flatMap { markupElement in + return contentCompiler.visit(markupElement) as! [RenderBlockContent] + } + ) + } + + let renderedRow = RenderBlockContent.Row( + numberOfColumns: numberOfColumns, + columns: renderedColumns + ) + + return [RenderBlockContent.row(renderedRow)] + } +} diff --git a/Sources/SwiftDocC/Semantics/SemanticAnalyzer.swift b/Sources/SwiftDocC/Semantics/SemanticAnalyzer.swift index b621f9b548..89112b41ff 100644 --- a/Sources/SwiftDocC/Semantics/SemanticAnalyzer.swift +++ b/Sources/SwiftDocC/Semantics/SemanticAnalyzer.swift @@ -156,9 +156,32 @@ struct SemanticAnalyzer: MarkupVisitor { _ = Snippet(from: blockDirective, source: source, for: bundle, in: context, problems: &problems) return nil default: - 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") - problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) - return nil + 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") + problems.append(Problem(diagnostic: diagnostic, possibleSolutions: [])) + + return nil + } + + guard let directive = directiveType.init( + from: blockDirective, + source: source, + for: bundle, + in: context, + problems: &problems + ) else { + return nil + } + + // Analyze any structured markup directives (like @Row or @Column) + // that are contained in the child markup of this directive. + if let markupContainingDirective = directive as? MarkupContaining { + for markupElement in markupContainingDirective.childMarkup { + _ = visit(markupElement) + } + } + + return directive as? Semantic } } diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 8ccb5c0ecf..10b2283538 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -428,6 +428,9 @@ { "$ref": "#/components/schemas/Columns" }, + { + "$ref": "#/components/schemas/Row" + }, { "$ref": "#/components/schemas/Aside" }, @@ -548,6 +551,47 @@ } } }, + "Row": { + "type": "object", + "required": [ + "kind", + "columns", + "numberOfColumns" + ], + "properties": { + "kind": { + "type": "string", + "enum": ["row"] + }, + "columns": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Column" + } + }, + "numberOfColumns": { + "type": "number" + } + } + }, + "Column": { + "type": "object", + "required": [ + "content", + "size" + ], + "properties": { + "content": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RenderBlockContent" + } + }, + "size": { + "type": "number" + } + } + }, "Aside": { "type": "object", "required": [ diff --git a/Tests/SwiftDocCTests/Model/DocumentationMarkupTests.swift b/Tests/SwiftDocCTests/Model/DocumentationMarkupTests.swift index 82f74e5446..50ff226230 100644 --- a/Tests/SwiftDocCTests/Model/DocumentationMarkupTests.swift +++ b/Tests/SwiftDocCTests/Model/DocumentationMarkupTests.swift @@ -77,13 +77,17 @@ class DocumentationMarkupTests: XCTestCase { My abstract __content__. """ let expected = """ - Text "My abstract " - Strong - └─ Text "content" - Text "." + BlockDirective name: "Directive" + └─ BlockDirective name: "NestedDirective" + Paragraph + ├─ Text "My abstract " + ├─ Strong + │ └─ Text "content" + └─ Text "." """ let model = DocumentationMarkup(markup: Document(parsing: source, options: .parseBlockDirectives)) - XCTAssertEqual(expected, model.abstractSection?.content.map({ $0.detachedFromParent.debugDescription() }).joined(separator: "\n")) + XCTAssertNil(model.abstractSection) + XCTAssertEqual(expected, model.discussionSection?.content.map({ $0.detachedFromParent.debugDescription() }).joined(separator: "\n")) } // Directives in between sections diff --git a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift index 3b2334439a..e5b5ec7ca2 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift @@ -1014,4 +1014,38 @@ class RenderNodeTranslatorTests: XCTestCase { XCTAssertEqual(l.code, ["middle()"]) } + + func testRowAndColumn() throws { + let (bundle, context) = try testBundleAndContext(named: "BookLikeContent") + let reference = ResolvedTopicReference( + bundleIdentifier: bundle.identifier, + path: "/documentation/BestBook/MyArticle", + sourceLanguage: .swift + ) + let article = try XCTUnwrap(context.entity(with: reference).semantic as? Article) + var translator = RenderNodeTranslator( + context: context, + bundle: bundle, + identifier: reference, + source: nil + ) + let renderNode = try XCTUnwrap(translator.visitArticle(article) as? RenderNode) + + let discussion = try XCTUnwrap( + renderNode.primaryContentSections.first( + where: { $0.kind == .content } + ) as? ContentRenderSection + ) + + guard case let .row(row) = discussion.content.dropFirst().first else { + XCTFail("Expected to find row as first child.") + return + } + + XCTAssertEqual(row.numberOfColumns, 8) + XCTAssertEqual(row.columns.first?.size, 3) + XCTAssertEqual(row.columns.first?.content.count, 1) + XCTAssertEqual(row.columns.last?.size, 5) + XCTAssertEqual(row.columns.last?.content.count, 3) + } } diff --git a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift index 29c2a98ce8..fb93e76375 100644 --- a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift +++ b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift @@ -20,6 +20,7 @@ class DirectiveIndexTests: XCTestCase { "Assessments", "Chapter", "Choice", + "Column", "DeprecationSummary", "DisplayName", "DocumentationExtension", @@ -28,6 +29,7 @@ class DirectiveIndexTests: XCTestCase { "Justification", "Metadata", "Redirected", + "Row", "Snippet", "Stack", "TechnologyRoot", @@ -38,4 +40,13 @@ class DirectiveIndexTests: XCTestCase { ] ) } + + func testDirectiveIndexHasExpectedRenderableDirectives() { + XCTAssertEqual( + DirectiveIndex.shared.renderableDirectives.keys.sorted(), + [ + "Row", + ] + ) + } } diff --git a/Tests/SwiftDocCTests/Semantics/RowTests.swift b/Tests/SwiftDocCTests/Semantics/RowTests.swift new file mode 100644 index 0000000000..e07564a898 --- /dev/null +++ b/Tests/SwiftDocCTests/Semantics/RowTests.swift @@ -0,0 +1,283 @@ +/* + 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 RowTests: XCTestCase { + func testNoColumns() throws { + let (renderBlockContent, problems, row) = try parseDirective(Row.self) { + """ + @Row + """ + } + + XCTAssertNotNil(row) + + XCTAssertEqual( + problems, + ["1: warning – org.swift.docc.HasAtLeastOne"] + ) + + XCTAssertEqual( + renderBlockContent, + .row(.init(numberOfColumns: 0, columns: [])) + ) + } + + func testInvalidParameters() throws { + do { + let (renderBlockContent, problems, row) = try parseDirective(Row.self) { + """ + @Row(columns: 3) { + @Column(what: true) { + Hello there. + } + + @Column(size: true) { + Hello there. + } + + @Column(size: 4) { + Hello there. + } + } + """ + } + + XCTAssertNotNil(row) + XCTAssertEqual( + problems, + [ + "1: warning – org.swift.docc.UnknownArgument", + "2: warning – org.swift.docc.UnknownArgument", + "6: warning – org.swift.docc.HasArgument.size.ConversionFailed", + ] + ) + + XCTAssertEqual( + renderBlockContent, + .row(RenderBlockContent.Row( + numberOfColumns: 6, + columns: [ + RenderBlockContent.Row.Column(size: 1, content: ["Hello there."]), + RenderBlockContent.Row.Column(size: 1, content: ["Hello there."]), + RenderBlockContent.Row.Column(size: 4, content: ["Hello there."]) + ] + )) + ) + } + + do { + let (_, problems, row) = try parseDirective(Row.self) { + """ + @Row(numberOfColumns: 3) { + @Column(size: 3) { + @Row { + @Column { + @Row { + @Column(weird: false) + } + } + } + } + } + """ + } + + XCTAssertNotNil(row) + XCTAssertEqual( + problems, + [ + "6: warning – org.swift.docc.UnknownArgument", + ] + ) + } + } + + func testInvalidChildren() throws { + do { + let (renderBlockContent, problems, row) = try parseDirective(Row.self) { + """ + @Row { + @Row { + @Column { + Hello there. + } + } + } + """ + } + + XCTAssertNotNil(row) + XCTAssertEqual( + problems, + [ + "1: warning – org.swift.docc.HasAtLeastOne", + "1: warning – org.swift.docc.Row.UnexpectedContent", + "2: warning – org.swift.docc.HasOnlyKnownDirectives", + ] + ) + + XCTAssertEqual( + renderBlockContent, + .row(RenderBlockContent.Row( + numberOfColumns: 0, + columns: [] + )) + ) + } + + do { + let (renderBlockContent, problems, row) = try parseDirective(Row.self) { + """ + @Row { + @Column { + @Column { + Hello there. + } + } + } + """ + } + + XCTAssertNotNil(row) + XCTAssertEqual( + problems, + [ + "3: warning – org.swift.docc.HasOnlyKnownDirectives", + ] + ) + + XCTAssertEqual( + renderBlockContent, + .row(RenderBlockContent.Row( + numberOfColumns: 1, + columns: [ + RenderBlockContent.Row.Column(size: 1, content: []) + ] + )) + ) + } + + do { + let (renderBlockContent, problems, row) = try parseDirective(Row.self) { + """ + @Row { + + } + """ + } + + XCTAssertNotNil(row) + XCTAssertEqual( + problems, + [ + "1: warning – org.swift.docc.HasAtLeastOne", + ] + ) + + XCTAssertEqual( + renderBlockContent, + .row(RenderBlockContent.Row(numberOfColumns: 0, columns: [])) + ) + } + } + + func testEmptyColumn() throws { + let (renderBlockContent, problems, row) = try parseDirective(Row.self) { + """ + @Row { + @Column + + @Column(size: 3) { + This is a wiiiiddde column. + } + + @Column + } + """ + } + + XCTAssertNotNil(row) + XCTAssertEqual(problems, []) + + XCTAssertEqual( + renderBlockContent, + .row(RenderBlockContent.Row( + numberOfColumns: 5, + columns: [ + RenderBlockContent.Row.Column(size: 1, content: []), + + RenderBlockContent.Row.Column( + size: 3, + content: ["This is a wiiiiddde column."] + ), + + RenderBlockContent.Row.Column(size: 1, content: []), + ] + )) + ) + } + + func testNestedRowAndColumns() throws { + let (renderBlockContent, problems, row) = try parseDirective(Row.self) { + """ + @Row { + @Column { + @Row { + @Column { + Hello + } + + @Column { + There + } + } + } + } + """ + } + + XCTAssertNotNil(row) + XCTAssertEqual(problems, []) + + XCTAssertEqual( + renderBlockContent, + .row(RenderBlockContent.Row( + numberOfColumns: 1, + columns: [ + RenderBlockContent.Row.Column( + size: 1, + content: [ + .row(RenderBlockContent.Row( + numberOfColumns: 2, + columns: [ + RenderBlockContent.Row.Column(size: 1, content: ["Hello"]), + RenderBlockContent.Row.Column(size: 1, content: ["There"]), + ] + )) + ] + ) + ] + )) + ) + } + +} + +extension RenderBlockContent: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = RenderBlockContent.paragraph(Paragraph(inlineContent: [.text(value)])) + } +} diff --git a/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/Info.plist b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/Info.plist new file mode 100644 index 0000000000..ea1b5b8610 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/Info.plist @@ -0,0 +1,12 @@ + + + + + CFBundleDisplayName + BestBook + CFBundleIdentifier + org.swift.docc.Book + CFBundleVersion + 0.1.0 + + diff --git a/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyArticle.md b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyArticle.md new file mode 100644 index 0000000000..31e3ed0588 --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyArticle.md @@ -0,0 +1,29 @@ +# My Article + +This is the abstract of my article. Nice! + +@Row(numberOfColumns: 8) { + @Column(size: 3) { + ![A great image](figure1) + } + + @Column(size: 5) { + This is a great image. With a lot of describing text next to it. + + And a second *paragraph*. + + @Row(kjk: test) { + @Column { + Hello + + Hi + } + + @Column { + There + } + } + } +} + + diff --git a/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyBook.md b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyBook.md new file mode 100644 index 0000000000..ac461699bf --- /dev/null +++ b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyBook.md @@ -0,0 +1,15 @@ +# My Book + +The best book. + +@Metadata { + @TechnologyRoot +} + +## Topics + +### Articles + +- + + diff --git a/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/figure1.png b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/figure1.png new file mode 100644 index 0000000000..091ac6ceb1 Binary files /dev/null and b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/figure1.png differ diff --git a/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/figure1~dark.png b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/figure1~dark.png new file mode 100644 index 0000000000..1eb1310e0e Binary files /dev/null and b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/figure1~dark.png differ diff --git a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift index 945c73cd81..52d541a2d2 100644 --- a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift +++ b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift @@ -11,6 +11,7 @@ import Foundation import XCTest @testable import SwiftDocC +import Markdown extension XCTestCase { @@ -76,4 +77,54 @@ extension XCTestCase { return bundles[0] } + func parseDirective( + _ directive: Directive.Type, + source: () -> String + ) throws -> (renderBlockContent: RenderBlockContent?, problemIdentifiers: [String], directive: Directive?) { + let bundle = DocumentationBundle( + info: DocumentationBundle.Info( + displayName: "Test", + identifier: "com.example.test", + version: "1.0" + ), + baseURL: URL(string: "https://example.com/example")!, + symbolGraphURLs: [], + markupURLs: [], + miscResourceURLs: [] + ) + let provider = PrebuiltLocalFileSystemDataProvider(bundles: [bundle]) + let workspace = DocumentationWorkspace() + try workspace.registerProvider(provider) + let context = try DocumentationContext(dataProvider: workspace) + + let document = Document(parsing: source(), options: .parseBlockDirectives) + + let blockDirectiveContainer = try XCTUnwrap(document.child(at: 0) as? BlockDirective) + + var analyzer = SemanticAnalyzer(source: nil, context: context, bundle: bundle) + let result = analyzer.visit(blockDirectiveContainer) + + let problemIDs = analyzer.problems.map { problem -> String in + let line = problem.diagnostic.range?.lowerBound.line.description ?? "unknown-line" + + return "\(line): \(problem.diagnostic.severity) – \(problem.diagnostic.identifier)" + }.sorted() + + guard let directive = result as? Directive else { + return (nil, problemIDs, nil) + } + + var contentCompiler = RenderContentCompiler( + context: context, + bundle: bundle, + identifier: ResolvedTopicReference( + bundleIdentifier: bundle.identifier, + path: "/test-path-123", + sourceLanguage: .swift + ) + ) + + let renderedContent = directive.render(with: &contentCompiler).first as? RenderBlockContent + return (renderedContent, problemIDs, directive) + } }