diff --git a/Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift b/Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift index 03ee1f81b7..1db073eb03 100644 --- a/Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift +++ b/Sources/SwiftDocC/Indexing/RenderBlockContent+TextIndexing.swift @@ -62,6 +62,8 @@ extension RenderBlockContent: TextIndexing { return row.columns.map { column in return column.content.rawIndexableTextContent(references: references) }.joined(separator: " ") + case .small(let small): + return small.inlineContent.rawIndexableTextContent(references: references) default: fatalError("unknown RenderBlockContent case in rawIndexableTextContent") } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index 58cac267c6..e66eb7875f 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -61,7 +61,11 @@ public enum RenderBlockContent: Equatable { /// A table that contains a list of row data. case table(Table) + /// A row in a grid-based layout system that describes a collection of columns. case row(Row) + + /// A paragraph of small print content that should be rendered in a small font. + case small(Small) // 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 @@ -427,6 +431,15 @@ public enum RenderBlockContent: Equatable { public let content: [RenderBlockContent] } } + + /// A paragraph of small print content that should be rendered in a small font. + /// + /// Small is based on HTML's `` tag and could contain content like legal, + /// license, or copyright text. + public struct Small: Codable, Equatable { + /// The inline content that should be rendered. + public let inlineContent: [RenderInlineContent] + } } // Codable conformance @@ -489,11 +502,15 @@ extension RenderBlockContent: Codable { columns: container.decode([Row.Column].self, forKey: .columns) ) ) + case .small: + self = try .small( + Small(inlineContent: container.decode([RenderInlineContent].self, forKey: .inlineContent)) + ) } } private enum BlockType: String, Codable { - case paragraph, aside, codeListing, heading, orderedList, unorderedList, step, endpointExample, dictionaryExample, table, termList, row + case paragraph, aside, codeListing, heading, orderedList, unorderedList, step, endpointExample, dictionaryExample, table, termList, row, small } private var type: BlockType { @@ -510,6 +527,7 @@ extension RenderBlockContent: Codable { case .table: return .table case .termList: return .termList case .row: return .row + case .small: return .small default: fatalError("unknown RenderBlockContent case in type property") } } @@ -559,6 +577,8 @@ extension RenderBlockContent: Codable { case .row(let row): try container.encode(row.numberOfColumns, forKey: .numberOfColumns) try container.encode(row.columns, forKey: .columns) + case .small(let small): + try container.encode(small.inlineContent, forKey: .inlineContent) default: fatalError("unknown RenderBlockContent case in encode method") } diff --git a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift index 3444770e8d..b3bd808c04 100644 --- a/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift +++ b/Sources/SwiftDocC/Semantics/DirectiveInfrastructure/DirectiveIndex.swift @@ -18,6 +18,7 @@ struct DirectiveIndex { DeprecationSummary.self, Row.self, Options.self, + Small.self, ] private static let topLevelTutorialDirectives: [AutomaticDirectiveConvertible.Type] = [ diff --git a/Sources/SwiftDocC/Semantics/Reference/Small.swift b/Sources/SwiftDocC/Semantics/Reference/Small.swift new file mode 100644 index 0000000000..48ad2d2e5c --- /dev/null +++ b/Sources/SwiftDocC/Semantics/Reference/Small.swift @@ -0,0 +1,80 @@ +/* + 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 small print text like legal, license, or copyright text that +/// should be rendered in a smaller font size. +/// +/// The `@Small` directive is based on HTML's small tag (``). It supports any inline markup +/// formatting like bold and italics but does not support more structured markup like ``Row`` +/// and ``Row/Column``. +/// +/// ```md +/// You can create a sloth using the ``init(name:color:power:)`` +/// initializer, or create randomly generated sloth using a +/// ``SlothGenerator``: +/// +/// let slothGenerator = MySlothGenerator(seed: randomSeed()) +/// let habitat = Habitat(isHumid: false, isWarm: true) +/// +/// // ... +/// +/// @Small { +/// _Licensed under Apache License v2.0 with Runtime Library Exception._ +/// } +/// ``` +public final class Small: Semantic, AutomaticDirectiveConvertible, MarkupContaining { + public let originalMarkup: BlockDirective + + /// The inline markup that should be rendered in a small font. + @ChildMarkup(numberOfParagraphs: .oneOrMore) + public private(set) var content: MarkupContainer + + static var keyPaths: [String : AnyKeyPath] = [ + "content" : \Small._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 Small: RenderableDirectiveConvertible { + func render(with contentCompiler: inout RenderContentCompiler) -> [RenderContent] { + // Render the content normally + let renderBlockContent = content.elements.flatMap { markupElement in + return contentCompiler.visit(markupElement) as! [RenderBlockContent] + } + + // Transform every paragraph in the render block content to a small paragraph + let transformedRenderBlockContent = renderBlockContent.map { block -> RenderBlockContent in + guard case let .paragraph(paragraph) = block else { + return block + } + + return .small(RenderBlockContent.Small(inlineContent: paragraph.inlineContent)) + } + + return transformedRenderBlockContent + } +} diff --git a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json index 4ab89e6518..edc7623be7 100644 --- a/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json +++ b/Sources/SwiftDocC/SwiftDocC.docc/Resources/RenderNode.spec.json @@ -434,6 +434,9 @@ { "$ref": "#/components/schemas/Aside" }, + { + "$ref": "#/components/schemas/Small" + }, { "$ref": "#/components/schemas/Heading" }, @@ -619,6 +622,25 @@ } } }, + "Small": { + "type": "object", + "required": [ + "type", + "inlineContent" + ], + "properties": { + "type": { + "type": "string", + "enum": ["small"] + }, + "inlineContent": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RenderInlineContent" + } + } + } + }, "Heading": { "type": "object", "required": [ diff --git a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift index 257bf2326b..60499005e1 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderNodeTranslatorTests.swift @@ -1058,4 +1058,37 @@ class RenderNodeTranslatorTests: XCTestCase { XCTAssertEqual(row.columns.last?.size, 5) XCTAssertEqual(row.columns.last?.content.count, 3) } + + func testSmall() 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 .small(small) = discussion.content.last else { + XCTFail("Expected to find small as last child.") + return + } + + XCTAssertEqual( + small.inlineContent, + [.text("Copyright (c) 2022 Apple Inc and the Swift Project authors. All Rights Reserved.")] + ) + } } diff --git a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift index 876e7af84a..cb6c52315e 100644 --- a/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift +++ b/Tests/SwiftDocCTests/Semantics/DirectiveInfrastructure/DirectiveIndexTests.swift @@ -33,6 +33,7 @@ class DirectiveIndexTests: XCTestCase { "Options", "Redirected", "Row", + "Small", "Snippet", "Stack", "TechnologyRoot", @@ -50,6 +51,7 @@ class DirectiveIndexTests: XCTestCase { DirectiveIndex.shared.renderableDirectives.keys.sorted(), [ "Row", + "Small", ] ) } diff --git a/Tests/SwiftDocCTests/Semantics/RowTests.swift b/Tests/SwiftDocCTests/Semantics/Reference/RowTests.swift similarity index 92% rename from Tests/SwiftDocCTests/Semantics/RowTests.swift rename to Tests/SwiftDocCTests/Semantics/Reference/RowTests.swift index e07564a898..716411ca7d 100644 --- a/Tests/SwiftDocCTests/Semantics/RowTests.swift +++ b/Tests/SwiftDocCTests/Semantics/Reference/RowTests.swift @@ -29,8 +29,9 @@ class RowTests: XCTestCase { ["1: warning – org.swift.docc.HasAtLeastOne"] ) + XCTAssertEqual(renderBlockContent.count, 1) XCTAssertEqual( - renderBlockContent, + renderBlockContent.first, .row(.init(numberOfColumns: 0, columns: [])) ) } @@ -65,8 +66,9 @@ class RowTests: XCTestCase { ] ) + XCTAssertEqual(renderBlockContent.count, 1) XCTAssertEqual( - renderBlockContent, + renderBlockContent.first, .row(RenderBlockContent.Row( numberOfColumns: 6, columns: [ @@ -129,8 +131,9 @@ class RowTests: XCTestCase { ] ) + XCTAssertEqual(renderBlockContent.count, 1) XCTAssertEqual( - renderBlockContent, + renderBlockContent.first, .row(RenderBlockContent.Row( numberOfColumns: 0, columns: [] @@ -159,8 +162,9 @@ class RowTests: XCTestCase { ] ) + XCTAssertEqual(renderBlockContent.count, 1) XCTAssertEqual( - renderBlockContent, + renderBlockContent.first, .row(RenderBlockContent.Row( numberOfColumns: 1, columns: [ @@ -187,8 +191,9 @@ class RowTests: XCTestCase { ] ) + XCTAssertEqual(renderBlockContent.count, 1) XCTAssertEqual( - renderBlockContent, + renderBlockContent.first, .row(RenderBlockContent.Row(numberOfColumns: 0, columns: [])) ) } @@ -212,8 +217,9 @@ class RowTests: XCTestCase { XCTAssertNotNil(row) XCTAssertEqual(problems, []) + XCTAssertEqual(renderBlockContent.count, 1) XCTAssertEqual( - renderBlockContent, + renderBlockContent.first, .row(RenderBlockContent.Row( numberOfColumns: 5, columns: [ @@ -252,8 +258,9 @@ class RowTests: XCTestCase { XCTAssertNotNil(row) XCTAssertEqual(problems, []) + XCTAssertEqual(renderBlockContent.count, 1) XCTAssertEqual( - renderBlockContent, + renderBlockContent.first, .row(RenderBlockContent.Row( numberOfColumns: 1, columns: [ diff --git a/Tests/SwiftDocCTests/Semantics/Reference/SmallTests.swift b/Tests/SwiftDocCTests/Semantics/Reference/SmallTests.swift new file mode 100644 index 0000000000..1500e22ad0 --- /dev/null +++ b/Tests/SwiftDocCTests/Semantics/Reference/SmallTests.swift @@ -0,0 +1,196 @@ +/* + 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 SmallTests: XCTestCase { + func testNoContent() throws { + let (renderBlockContent, problems, small) = try parseDirective(Small.self) { + """ + @Small + """ + } + + XCTAssertNotNil(small) + + XCTAssertEqual( + problems, + ["1: warning – org.swift.docc.Small.HasContent"] + ) + + XCTAssertEqual(renderBlockContent, []) + } + + func testHasContent() throws { + do { + let (renderBlockContent, problems, small) = try parseDirective(Small.self) { + """ + @Small { + This is my copyright text. + } + """ + } + + XCTAssertNotNil(small) + + XCTAssertEqual(problems, []) + + XCTAssertEqual(renderBlockContent.count, 1) + XCTAssertEqual( + renderBlockContent.first, + .small(RenderBlockContent.Small( + inlineContent: [.text("This is my copyright text.")] + )) + ) + } + + do { + let (renderBlockContent, problems, small) = try parseDirective(Small.self) { + """ + @Small { + This is my copyright text. + + And a second line of copyright text. + } + """ + } + + XCTAssertNotNil(small) + + XCTAssertEqual(problems, []) + + XCTAssertEqual(renderBlockContent.count, 2) + XCTAssertEqual( + renderBlockContent, + [ + .small(RenderBlockContent.Small( + inlineContent: [.text("This is my copyright text.")] + )), + .small(RenderBlockContent.Small( + inlineContent: [.text("And a second line of copyright text.")] + )), + ] + ) + } + + do { + let (renderBlockContent, problems, small) = try parseDirective(Small.self) { + """ + @Small { + This is my *formatted* `copyright` **text**. + } + """ + } + + XCTAssertNotNil(small) + + XCTAssertEqual(problems, []) + + XCTAssertEqual(renderBlockContent.count, 1) + XCTAssertEqual( + renderBlockContent.first, + .small(RenderBlockContent.Small( + inlineContent: [ + .text("This is my "), + .emphasis(inlineContent: [.text("formatted")]), + .text(" "), + .codeVoice(code: "copyright"), + .text(" "), + .strong(inlineContent: [.text("text")]), + .text(".") + ] + )) + ) + } + } + + func testEmitsWarningWhenContainsStructuredMarkup() throws { + do { + let (renderBlockContent, problems, small) = try parseDirective(Small.self) { + """ + @Small { + This is my copyright text. + + @Row { + @Column { + This is copyright text in a column. + } + + @Column { + Second column. + } + } + + And final copyright text. + } + """ + } + + XCTAssertNotNil(small) + XCTAssertEqual(problems, ["4: warning – org.swift.docc.HasOnlyKnownDirectives"]) + XCTAssertEqual(renderBlockContent.count, 3) + } + } + + func testSmallInsideOfColumn() throws { + do { + let (renderBlockContent, problems, row) = try parseDirective(Row.self) { + """ + @Row { + @Column { + Regular text. + + @Small { + Small text. + } + } + + @Column { + Second column of regular text. + } + } + """ + } + + XCTAssertNotNil(row) + XCTAssertEqual(problems, []) + XCTAssertEqual(renderBlockContent.count, 1) + XCTAssertEqual( + renderBlockContent.first, + .row(RenderBlockContent.Row( + numberOfColumns: 2, + columns: [ + RenderBlockContent.Row.Column( + size: 1, + content: [ + "Regular text.", + .small(RenderBlockContent.Small( + inlineContent: [.text("Small text.")] + )), + ] + ), + + RenderBlockContent.Row.Column( + size: 1, + content: [ + "Second column of regular text.", + ] + ), + ] + )) + ) + + } + } +} diff --git a/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyArticle.md b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyArticle.md index 31e3ed0588..02d74d6d01 100644 --- a/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyArticle.md +++ b/Tests/SwiftDocCTests/Test Bundles/BookLikeContent.docc/MyArticle.md @@ -26,4 +26,8 @@ This is the abstract of my article. Nice! } } +@Small { + Copyright (c) 2022 Apple Inc and the Swift Project authors. All Rights Reserved. +} + diff --git a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift index 65108a3a09..cfd28057a1 100644 --- a/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift +++ b/Tests/SwiftDocCTests/XCTestCase+LoadingTestData.swift @@ -128,7 +128,7 @@ extension XCTestCase { func parseDirective( _ directive: Directive.Type, source: () -> String - ) throws -> (renderBlockContent: RenderBlockContent?, problemIdentifiers: [String], directive: Directive?) { + ) throws -> (renderBlockContent: [RenderBlockContent], problemIdentifiers: [String], directive: Directive?) { let (bundle, context) = try testBundleAndContext() let document = Document(parsing: source(), options: .parseBlockDirectives) @@ -145,7 +145,7 @@ extension XCTestCase { }.sorted() guard let directive = result as? Directive else { - return (nil, problemIDs, nil) + return ([], problemIDs, nil) } var contentCompiler = RenderContentCompiler( @@ -158,7 +158,9 @@ extension XCTestCase { ) ) - let renderedContent = directive.render(with: &contentCompiler).first as? RenderBlockContent + let renderedContent = try XCTUnwrap( + directive.render(with: &contentCompiler) as? [RenderBlockContent] + ) return (renderedContent, problemIDs, directive) } }