diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent+Capitalization.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent+Capitalization.swift index e18b3e3d3f..34bfd43f4e 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent+Capitalization.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent+Capitalization.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2024 Apple Inc. and the Swift project authors + Copyright (c) 2024-2025 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information @@ -68,7 +68,7 @@ extension RenderBlockContent.Paragraph { extension RenderBlockContent.Aside { func capitalizingFirstWord() -> RenderBlockContent.Aside { - return .init(style: self.style, content: self.content.capitalizingFirstWord()) + return .init(style: self.style, name: self.name, content: self.content.capitalizingFirstWord()) } } diff --git a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift index ad1827d012..67925f93d7 100644 --- a/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift +++ b/Sources/SwiftDocC/Model/Rendering/Content/RenderBlockContent.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021-2024 Apple Inc. and the Swift project authors + Copyright (c) 2021-2025 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information @@ -103,17 +103,95 @@ public enum RenderBlockContent: Equatable { } /// An aside block. - public struct Aside: Equatable { + public struct Aside: Codable, Equatable { + /// The style of this aside block. public var style: AsideStyle + /// The name of this aside block. + public var name: String + /// The content inside this aside block. public var content: [RenderBlockContent] + /// Creates an aside from an aside style and block content. + /// + /// The new aside will have a name set to the capitalized style. + /// + /// - Parameters: + /// - style: The style of this aside + /// - content: The block content to display in the aside public init(style: AsideStyle, content: [RenderBlockContent]) { self.style = style + self.name = style.rawValue.capitalized self.content = content } + + /// Creates an aside from a name and block content. + /// + /// The new aside will have a style set to the lowercased name. + /// + /// - Parameters: + /// - name: The name of the aside. + /// - content: The block content to display in the aside + /// + /// > Note: + /// > If the lowercased name doesn't match one of the aside styles supported + /// > by DocC Render (one of note, tip, experiment, important, or warning) this will + /// > set the style to be note. + public init(name: String, content: [RenderBlockContent]) { + self.style = .init(rawValue: name) + self.name = name + self.content = content + } + + /// Creates an aside from an aside style, name and block content. + /// + /// - Parameters: + /// - style: The style of the aside + /// - name: The name of the aside + /// - content: The block content to display in the aside + public init(style: AsideStyle, name: String, content: [RenderBlockContent]) { + self.style = style + self.name = name + self.content = content + } + + /// Creates an aside from a Swift Markdown aside kind and block content. + /// + /// The new aside will have a name and style based on the display name of the + /// Swift Markdown aside kind. + /// + /// - Parameters: + /// - asideKind: The Swift Markdown aside kind + /// - content: The block content to display in the aside + /// + /// > Note: + /// > If the Swift Markdown aside kind is unknown, then the new aside will + /// > have a name and style set to the Swift Markdown aside kind, + /// > capitalized if necessary. + public init(asideKind: Markdown.Aside.Kind, content: [RenderBlockContent]) { + let name: String + if let knownDisplayName = Self.knownDisplayNames[asideKind.rawValue.lowercased()] { + name = knownDisplayName + } else if asideKind.rawValue.contains(where: \.isUppercase) { + // Assume the content has specific and intentional capitalization. + name = asideKind.rawValue + } else { + // Avoid an all lower case display name. + name = asideKind.rawValue.capitalized + } + + self.init( + style: .init(asideKind: asideKind), + name: name, + content: content + ) + } + + private static let knownDisplayNames: [String: String] = Dictionary( + uniqueKeysWithValues: Markdown.Aside.Kind.allCases.map { ($0.rawValue.lowercased(), $0.displayName) } + ) } /// A block of sample code. @@ -515,10 +593,7 @@ public enum RenderBlockContent: Equatable { /// A type the describes an aside style. public struct AsideStyle: Codable, Equatable { - private static let knownDisplayNames: [String: String] = Dictionary( - uniqueKeysWithValues: Markdown.Aside.Kind.allCases.map { ($0.rawValue.lowercased(), $0.displayName) } - ) - + /// Returns a Boolean value indicating whether two aside styles are equal. /// /// The comparison uses ``rawValue`` and is case-insensitive. @@ -529,75 +604,77 @@ public enum RenderBlockContent: Equatable { public static func ==(lhs: AsideStyle, rhs: AsideStyle) -> Bool { lhs.rawValue.caseInsensitiveCompare(rhs.rawValue) == .orderedSame } - + /// The underlying raw string value. public var rawValue: String /// The heading text to use when rendering this style of aside. + @available(*, deprecated, message: "Use 'Aside.name' instead. This deprecated API will be removed after 6.4 is released.") public var displayName: String { - if let value = Self.knownDisplayNames[rawValue.lowercased()] { - return value - } else if rawValue.contains(where: \.isUppercase) { - // If any character is upper-cased, assume the content has - // specific casing and return the raw value. - return rawValue - } else { - return rawValue.capitalized - } + return rawValue.capitalized } - /// The style of aside to use when rendering. + /// Creates an aside style. + /// + /// The new aside style's underlying raw string value will be lowercased. + /// + /// - Parameters: + /// - rawValue: The underlying raw string value. /// - /// DocC Render currently has five styles of asides: Note, Tip, Experiment, Important, and Warning. Asides - /// of these styles can emit their own style into the output, but other styles need to be rendered as one of - /// these five styles. This property maps aside styles to the render style used in the output. - var renderKind: String { - switch rawValue.lowercased() { - case let lowercasedRawValue - where [ - "important", - "warning", - "experiment", - "tip" - ].contains(lowercasedRawValue): - return lowercasedRawValue + /// > Note: + /// > If the lowercased raw value doesn't match one of the aside styles supported + /// > by DocC Render (one of note, tip, experiment, important, or warning) the + /// > new aside style's raw value will be set to note. + public init(rawValue: String) { + let lowercased = rawValue.lowercased() + switch lowercased { + case "important", "warning", "experiment", "tip": + self.rawValue = lowercased default: - return "note" + self.rawValue = "note" } } - /// Creates an aside type for the specified aside kind. - /// - Parameter asideKind: The aside kind that provides the display name. + /// Creates an aside style from a Swift Markdown aside kind. + /// + /// The new aside style's underlying raw string value will be the + /// markdown aside kind's raw value. + /// + /// - Parameters: + /// - rawValue: The Swift Markdown aside kind + /// + /// > Note: + /// > If the lowercased raw value doesn't match one of the aside styles supported + /// > by DocC Render (one of note, tip, experiment, important, or warning) the + /// > new aside style's raw value will be set to note. public init(asideKind: Markdown.Aside.Kind) { - self.rawValue = asideKind.rawValue - } - - /// Creates an aside style for the specified raw value. - /// - Parameter rawValue: The heading text to use when rendering this style of aside. - public init(rawValue: String) { - self.rawValue = rawValue + self.init(rawValue: asideKind.rawValue) } - + /// Creates an aside style with the specified display name. /// - Parameter displayName: The heading text to use when rendering this style of aside. + @available(*, deprecated, renamed: "init(rawValue:)", message: "Use 'init(rawValue:)' instead. This deprecated API will be removed after 6.4 is released.") public init(displayName: String) { - self.rawValue = Self.knownDisplayNames.first(where: { $0.value == displayName })?.key ?? displayName + self.init(rawValue: displayName) } /// Encodes the aside style into the specified encoder. /// - Parameter encoder: The encoder to write data to. public func encode(to encoder: any Encoder) throws { - // For backwards compatibility, encode only the display name and - // not a key-value pair. var container = encoder.singleValueContainer() try container.encode(rawValue) } - - /// Creates an aside style by decoding the specified decoder. + + /// Creates an aside style by decoding from the specified decoder. /// - Parameter decoder: The decoder to read data from. + /// + /// > Note: + /// > If the lowercased raw value doesn't match one of the aside styles supported + /// > by DocC Render (one of note, tip, experiment, important, or warning) the + /// > new aside style's raw value will be set to note. public init(from decoder: any Decoder) throws { let container = try decoder.singleValueContainer() - self.rawValue = try container.decode(String.self) + self.init(rawValue: try container.decode(String.self)) } } @@ -930,11 +1007,17 @@ extension RenderBlockContent: Codable { case .paragraph: self = try .paragraph(.init(inlineContent: container.decode([RenderInlineContent].self, forKey: .inlineContent))) case .aside: - var style = try container.decode(AsideStyle.self, forKey: .style) - if let displayName = try container.decodeIfPresent(String.self, forKey: .name) { - style = AsideStyle(displayName: displayName) + let aside: Aside + let content = try container.decode([RenderBlockContent].self, forKey: .content) + let style = try container.decode(AsideStyle.self, forKey: .style) + if let name = try container.decodeIfPresent(String.self, forKey: .name) { + // Retain both the style and name, if both are present. + aside = .init(style: style, name: name, content: content) + } else { + // Or if the name is not specified, set the name based on the style. + aside = .init(style: style, content: content) } - self = try .aside(.init(style: style, content: container.decode([RenderBlockContent].self, forKey: .content))) + self = .aside(aside) case .codeListing: let copy = FeatureFlags.current.isExperimentalCodeBlockAnnotationsEnabled let options: CodeBlockOptions? @@ -1049,8 +1132,8 @@ extension RenderBlockContent: Codable { case .paragraph(let p): try container.encode(p.inlineContent, forKey: .inlineContent) case .aside(let a): - try container.encode(a.style.renderKind, forKey: .style) - try container.encode(a.style.displayName, forKey: .name) + try container.encode(a.style, forKey: .style) + try container.encode(a.name, forKey: .name) try container.encode(a.content, forKey: .content) case .codeListing(let l): try container.encode(l.syntax, forKey: .syntax) diff --git a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift index 2f7ddd4d31..6a0996ef1e 100644 --- a/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift +++ b/Sources/SwiftDocC/Model/Rendering/RenderContentCompiler.swift @@ -36,10 +36,10 @@ struct RenderContentCompiler: MarkupVisitor { let aside = Aside(blockQuote) let newAside = RenderBlockContent.Aside( - style: RenderBlockContent.AsideStyle(asideKind: aside.kind), + asideKind: aside.kind, content: aside.content.reduce(into: [], { result, child in result.append(contentsOf: visit(child))}) as! [RenderBlockContent] ) - + return [RenderBlockContent.aside(newAside.capitalizingFirstWord())] } @@ -390,7 +390,7 @@ struct RenderContentCompiler: MarkupVisitor { } } return [RenderBlockContent.aside(.init( - style: .init(asideKind: .note), + asideKind: .note, content: content ))] } diff --git a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift index 73b4a508ed..12b38c560a 100644 --- a/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift +++ b/Tests/SwiftDocCTests/Model/SemaToRenderNodeTests.swift @@ -2444,34 +2444,6 @@ Document XCTAssertNotNil(renderReference.navigatorTitle) } - let asidesStressTest: [RenderBlockContent] = [ - .aside(.init(style: .init(rawValue: "Note"), content: [.paragraph(.init(inlineContent: [.text("This is a note.")]))])), - .aside(.init(style: .init(rawValue: "Tip"), content: [.paragraph(.init(inlineContent: [.text("Here’s a tip.")]))])), - .aside(.init(style: .init(rawValue: "Important"), content: [.paragraph(.init(inlineContent: [.text("Keep this in mind.")]))])), - .aside(.init(style: .init(rawValue: "Experiment"), content: [.paragraph(.init(inlineContent: [.text("Try this out.")]))])), - .aside(.init(style: .init(rawValue: "Warning"), content: [.paragraph(.init(inlineContent: [.text("Watch out for this.")]))])), - .aside(.init(style: .init(rawValue: "Attention"), content: [.paragraph(.init(inlineContent: [.text("Head’s up!")]))])), - .aside(.init(style: .init(rawValue: "Author"), content: [.paragraph(.init(inlineContent: [.text("I wrote this.")]))])), - .aside(.init(style: .init(rawValue: "Authors"), content: [.paragraph(.init(inlineContent: [.text("We wrote this.")]))])), - .aside(.init(style: .init(rawValue: "Bug"), content: [.paragraph(.init(inlineContent: [.text("This is wrong.")]))])), - .aside(.init(style: .init(rawValue: "Complexity"), content: [.paragraph(.init(inlineContent: [.text("This takes time.")]))])), - .aside(.init(style: .init(rawValue: "Copyright"), content: [.paragraph(.init(inlineContent: [.text("2021 Apple Inc.")]))])), - .aside(.init(style: .init(rawValue: "Date"), content: [.paragraph(.init(inlineContent: [.text("1 January 1970")]))])), - .aside(.init(style: .init(rawValue: "Invariant"), content: [.paragraph(.init(inlineContent: [.text("This shouldn’t change.")]))])), - .aside(.init(style: .init(rawValue: "MutatingVariant"), content: [.paragraph(.init(inlineContent: [.text("This will change.")]))])), - .aside(.init(style: .init(rawValue: "NonMutatingVariant"), content: [.paragraph(.init(inlineContent: [.text("This changes, but not in the data.")]))])), - .aside(.init(style: .init(rawValue: "Postcondition"), content: [.paragraph(.init(inlineContent: [.text("After calling, this should be true.")]))])), - .aside(.init(style: .init(rawValue: "Precondition"), content: [.paragraph(.init(inlineContent: [.text("Before calling, this should be true.")]))])), - .aside(.init(style: .init(rawValue: "Remark"), content: [.paragraph(.init(inlineContent: [.text("Something you should know.")]))])), - .aside(.init(style: .init(rawValue: "Requires"), content: [.paragraph(.init(inlineContent: [.text("This needs something.")]))])), - .aside(.init(style: .init(rawValue: "Since"), content: [.paragraph(.init(inlineContent: [.text("The beginning of time.")]))])), - .aside(.init(style: .init(rawValue: "Todo"), content: [.paragraph(.init(inlineContent: [.text("This needs work.")]))])), - .aside(.init(style: .init(rawValue: "Version"), content: [.paragraph(.init(inlineContent: [.text("3.1.4")]))])), - .aside(.init(style: .init(rawValue: "SeeAlso"), content: [.paragraph(.init(inlineContent: [.text("This other thing.")]))])), - .aside(.init(style: .init(rawValue: "SeeAlso"), content: [.paragraph(.init(inlineContent: [.text("And this other thing.")]))])), - .aside(.init(style: .init(rawValue: "Throws"), content: [.paragraph(.init(inlineContent: [.text("A serious error.")]))])), - ] - func testBareTechnology() async throws { let (_, _, context) = try await testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests") { url in try """ @@ -2604,29 +2576,86 @@ Document /// Ensures we render our supported asides from symbol-graph content correctly, whether as a blockquote or as a list item. func testRenderAsides() async throws { - let asidesSGFURL = Bundle.module.url( - forResource: "Asides.symbols", withExtension: "json", subdirectory: "Test Resources")! - let (_, _, context) = try await testBundleAndContext(copying: "LegacyBundle_DoNotUseInNewTests", excludingPaths: []) { url in - try? FileManager.default.copyItem(at: asidesSGFURL, to: url.appendingPathComponent("Asides.symbols.json")) - } - - // Both of these symbols have the same content; one just has its asides as list items and the other has blockquotes. - let testReference: (ResolvedTopicReference) throws -> () = { myFuncReference in + let asidesSGFURL = Bundle.module.url(forResource: "Asides.symbols", withExtension: "json", subdirectory: "Test Resources")! + let catalog = Folder(name: "unit-test.docc", content: [ + CopyOfFile(original: asidesSGFURL, newName: "Asides.symbols.json"), + ]) + + let (_, context) = try await loadBundle(catalog: catalog) + + func testReference( + myFuncReference: ResolvedTopicReference, + expectedAsides: [RenderBlockContent.Aside], + file: StaticString = #filePath, + line: UInt = #line + ) throws { let node = try context.entity(with: myFuncReference) let symbol = node.semantic as! Symbol var translator = RenderNodeTranslator(context: context, identifier: node.reference) let renderNode = translator.visit(symbol) as! RenderNode - let asides = try XCTUnwrap(renderNode.primaryContentSections.first(where: { $0.kind == .content }) as? ContentRenderSection) - - XCTAssertEqual(Array(asides.content.dropFirst()), self.asidesStressTest) + let contentSection = try XCTUnwrap(renderNode.primaryContentSections.first(where: { $0.kind == .content }) as? ContentRenderSection) + let blockContent = contentSection.content.dropFirst() + let asides: [RenderBlockContent.Aside] = blockContent.compactMap { block in + guard case let .aside(aside) = block else { + XCTFail("Unexpected block content in Asides.symbols.json") + return nil + } + return aside + } + XCTAssertEqual(expectedAsides.count, asides.count) + + for (expectedAside, aside) in zip(expectedAsides, asides) { + XCTAssertEqual(expectedAside.style, aside.style, file: file, line: line) + XCTAssertEqual(expectedAside.name, aside.name, file: file, line: line) + XCTAssertEqual(expectedAside.content, aside.content, file: file, line: line) + } } - - let dashReference = ResolvedTopicReference(bundleID: context.inputs.id, path: "/documentation/Asides/dashAsides()", sourceLanguage: .swift) + + func testContent(_ text: String) -> [RenderBlockContent] { + return [.paragraph( + .init( + inlineContent: [ + .text(text) + ] + ) + )] + } + + // Aside blocks from Tests/SwiftDocCTests/Test Resources/Asides.symbols.json + let expectedAsides: [RenderBlockContent.Aside] = [ + .init(name: "Note", content: testContent("This is a note.")), + .init(name: "Tip", content: testContent("Here’s a tip.")), + .init(name: "Important", content: testContent("Keep this in mind.")), + .init(name: "Experiment", content: testContent("Try this out.")), + .init(name: "Warning", content: testContent("Watch out for this.")), + .init(name: "Attention", content: testContent("Head’s up!")), + .init(name: "Author", content: testContent("I wrote this.")), + .init(name: "Authors", content: testContent("We wrote this.")), + .init(name: "Bug", content: testContent("This is wrong.")), + .init(name: "Complexity", content: testContent("This takes time.")), + .init(name: "Copyright", content: testContent("2021 Apple Inc.")), + .init(name: "Date", content: testContent("1 January 1970")), + .init(name: "Invariant", content: testContent("This shouldn’t change.")), + .init(name: "Mutating Variant", content: testContent("This will change.")), + .init(name: "Non-Mutating Variant", content: testContent("This changes, but not in the data.")), + .init(name: "Postcondition", content: testContent("After calling, this should be true.")), + .init(name: "Precondition", content: testContent("Before calling, this should be true.")), + .init(name: "Remark", content: testContent("Something you should know.")), + .init(name: "Requires", content: testContent("This needs something.")), + .init(name: "Since", content: testContent("The beginning of time.")), + .init(name: "To Do", content: testContent("This needs work.")), + .init(name: "Version", content: testContent("3.1.4")), + .init(name: "See Also", content: testContent("This other thing.")), + .init(name: "See Also", content: testContent("And this other thing.")), + .init(name: "Throws", content: testContent("A serious error.")), + ] + let quoteReference = ResolvedTopicReference(bundleID: context.inputs.id, path: "/documentation/Asides/quoteAsides()", sourceLanguage: .swift) - - try testReference(dashReference) - try testReference(quoteReference) + try testReference(myFuncReference: quoteReference, expectedAsides: expectedAsides) + + let dashReference = ResolvedTopicReference(bundleID: context.inputs.id, path: "/documentation/Asides/dashAsides()", sourceLanguage: .swift) + try testReference(myFuncReference: dashReference, expectedAsides: expectedAsides) } /// Tests parsing origin data from symbol graph. @@ -2958,96 +2987,174 @@ Document XCTAssertNil(renderNode.abstract) } - func testAsidesDecoding() throws { - try assertRoundTripCoding(asidesStressTest) + // The 5 standard styles are encoded and decoded. The names are set to the capitalized style name. + func testEncodingAsidesStandardStyles() throws { + let expectedContent: [RenderBlockContent] = [.paragraph(.init(inlineContent: [.text("This is a note...")]))] + let styles = [ + "note", + "important", + "warning", + "experiment", + "tip", + ] + for style in styles { + let aside: RenderBlockContent = .aside( + .init(style: .init(rawValue: style), content: expectedContent) + ) + let expectedJson = """ + {"content":[{"inlineContent":[{"text":"This is a note...","type":"text"}],"type":"paragraph"}],"name":"\(style.capitalized)","style":"\(style)","type":"aside"} + """ + // Test encoding + try assertJSONEncoding(aside, jsonSortedKeysNoWhitespace: expectedJson) + // Test decoding + try assertJSONRepresentation(aside, expectedJson) + } + } + + // The 5 standard styles can also be specified by name. The capitalization of the name is retained. + // The style is always lowercase. + func testEncodingAsidesStandardNames() throws { + let expectedContent: [RenderBlockContent] = [.paragraph(.init(inlineContent: [.text("This is a note...")]))] + let names = [ + "note", + "important", + "warning", + "experiment", + "tip", + "Note", + "Important", + "Warning", + "Experiment", + "Tip", + ] + for name in names { + let aside: RenderBlockContent = .aside( + .init(name: name, content: expectedContent) + ) + let expectedJson = """ + {"content":[{"inlineContent":[{"text":"This is a note...","type":"text"}],"type":"paragraph"}],"name":"\(name)","style":"\(name.lowercased())","type":"aside"} + """ + // Test encoding + try assertJSONEncoding(aside, jsonSortedKeysNoWhitespace: expectedJson) + // Test decoding + try assertJSONRepresentation(aside, expectedJson) + } + } - try assertJSONRepresentation( - asidesStressTest, - """ - [ - {"type":"aside", "style":"note", "name":"Note", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This is a note."}]}]}, - {"type":"aside", "style":"tip", "name":"Tip", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"Here’s a tip."}]}]}, - {"type":"aside", "style":"important", "name":"Important", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"Keep this in mind."}]}]}, - {"type":"aside", "style":"experiment","name":"Experiment", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"Try this out."}]}]}, - {"type":"aside", "style":"warning", "name":"Warning", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"Watch out for this."}]}]}, - {"type":"aside", "style":"note", "name":"Attention", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"Head’s up!"}]}]}, - {"type":"aside", "style":"note", "name":"Author", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"I wrote this."}]}]}, - {"type":"aside", "style":"note", "name":"Authors", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"We wrote this."}]}]}, - {"type":"aside", "style":"note", "name":"Bug", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This is wrong."}]}]}, - {"type":"aside", "style":"note", "name":"Complexity", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This takes time."}]}]}, - {"type":"aside", "style":"note", "name":"Copyright", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"2021 Apple Inc."}]}]}, - {"type":"aside", "style":"note", "name":"Date", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"1 January 1970"}]}]}, - {"type":"aside", "style":"note", "name":"Invariant", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This shouldn’t change."}]}]}, - {"type":"aside", "style":"note", "name":"Mutating Variant", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This will change."}]}]}, - {"type":"aside", "style":"note", "name":"Non-Mutating Variant", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This changes, but not in the data."}]}]}, - {"type":"aside", "style":"note", "name":"Postcondition", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"After calling, this should be true."}]}]}, - {"type":"aside", "style":"note", "name":"Precondition", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"Before calling, this should be true."}]}]}, - {"type":"aside", "style":"note", "name":"Remark", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"Something you should know."}]}]}, - {"type":"aside", "style":"note", "name":"Requires", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This needs something."}]}]}, - {"type":"aside", "style":"note", "name":"Since", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"The beginning of time."}]}]}, - {"type":"aside", "style":"note", "name":"To Do", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This needs work."}]}]}, - {"type":"aside", "style":"note", "name":"Version", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"3.1.4"}]}]}, - {"type":"aside", "style":"note", "name":"See Also", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"This other thing."}]}]}, - {"type":"aside", "style":"note", "name":"See Also", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"And this other thing."}]}]}, - {"type":"aside", "style":"note", "name":"Throws", - "content": [{"type":"paragraph", "inlineContent":[{"type":"text", "text":"A serious error."}]}]}, - ] - """) + // Unknown, custom styles are ignored and coerced to style="note" and name="Note" + func testEncodingAsideCustomStyles() throws { + let expectedContent: [RenderBlockContent] = [.paragraph(.init(inlineContent: [.text("This is a note...")]))] + let styles = [ + "custom", + "other", + "something-else", + ] + for style in styles { + + let aside: RenderBlockContent = .aside( + .init(style: .init(rawValue: style), content: expectedContent) + ) + let expectedJson = """ + {"content":[{"inlineContent":[{"text":"This is a note...","type":"text"}],"type":"paragraph"}],"name":"Note","style":"note","type":"aside"} + """ + // Test encoding + try assertJSONEncoding(aside, jsonSortedKeysNoWhitespace: expectedJson) + // Test decoding + try assertJSONRepresentation(aside, expectedJson) + } + } - // While decoding, overwrite the style with the name, if both are specified. We expect the style's raw value - // to be "Custom Title", not "important" in this example. - try assertJSONRepresentation( - RenderBlockContent.aside( + // Custom names are supported using style="note" + func testEncodingAsideCustomNames() throws { + let expectedContent: [RenderBlockContent] = [.paragraph(.init(inlineContent: [.text("This is a note...")]))] + let names = [ + "Custom", + "Other", + "Something Else", + ] + for name in names { + let aside: RenderBlockContent = .aside( + .init(name: name, content: expectedContent) + ) + let expectedJson = """ + {"content":[{"inlineContent":[{"text":"This is a note...","type":"text"}],"type":"paragraph"}],"name":"\(name)","style":"note","type":"aside"} + """ + // Test encoding + try assertJSONEncoding(aside, jsonSortedKeysNoWhitespace: expectedJson) + // Test decoding + try assertJSONRepresentation(aside, expectedJson) + } + } + + // Custom names are supported using style="tip", by specifying both the style and name + func testEncodingTipAsideCustomNames() throws { + let expectedContent: [RenderBlockContent] = [.paragraph(.init(inlineContent: [.text("This is a note...")]))] + let names = [ + "Custom", + "Other", + "Something Else", + ] + for name in names { + let aside: RenderBlockContent = .aside( .init( - style: .init(rawValue: "Custom Title"), - content: [.paragraph(.init(inlineContent: [.text("This is a custom title...")]))] + style: .init(rawValue: "tip"), + name: name, + content: expectedContent ) - ), - """ - { - "type": "aside", - "content": [ - { - "type": "paragraph", - "inlineContent": [ - { - "type": "text", - "text": "This is a custom title..." - } - ] - } - ], - "style": "important", - "name": "Custom Title" + ) + let expectedJson = """ + {"content":[{"inlineContent":[{"text":"This is a note...","type":"text"}],"type":"paragraph"}],"name":"\(name)","style":"tip","type":"aside"} + """ + // Test encoding + try assertJSONEncoding(aside, jsonSortedKeysNoWhitespace: expectedJson) + // Test decoding + try assertJSONRepresentation(aside, expectedJson) + } + } + + // Asides with a style matching a known kind of Swift Markdown aside are rendered using the display name of the + // Swift Markdown aside kind. + func testEncodingAsideKnownMarkdownKind() throws { + let expectedContent: [RenderBlockContent] = [.paragraph(.init(inlineContent: [.text("This is a note...")]))] + for kind in Aside.Kind.allCases { + let aside: RenderBlockContent = .aside( + .init(asideKind: kind, content: expectedContent) + ) + // This will return one of the DocC Render supported styles, or rawValue="note" + let style = RenderBlockContent.AsideStyle(asideKind: kind) + let expectedJson = """ + {"content":[{"inlineContent":[{"text":"This is a note...","type":"text"}],"type":"paragraph"}],"name":"\(kind.displayName)","style":"\(style.rawValue)","type":"aside"} + """ + // Test encoding + try assertJSONEncoding(aside, jsonSortedKeysNoWhitespace: expectedJson) + // Test decoding + try assertJSONRepresentation(aside, expectedJson) + } + } + + // Asides with a custom/unknown Swift Markdown aside kind + func testEncodingAsideUnknownMarkdownKind() throws { + let expectedContent: [RenderBlockContent] = [.paragraph(.init(inlineContent: [.text("This is a note...")]))] + for kind in [ + "Something Special", + "No Idea What This Is", + ] { + guard let asideKind = Markdown.Aside.Kind.init(rawValue: kind) else { + XCTFail("Unexpected Markdown.Aside.Kind.rawValue: \(kind)") + return } - """) - - for style in Aside.Kind.allCases.map({ RenderBlockContent.AsideStyle(asideKind: $0) }) + [.init(displayName: "Custom Title")] { - try assertRoundTripCoding(RenderBlockContent.aside(.init(style: style, content: [.paragraph(.init(inlineContent: [.text("This is a custom title...")]))]))) + let aside: RenderBlockContent = .aside( + .init(asideKind: asideKind, content: expectedContent) + ) + // This will return one of the DocC Render supported styles, or rawValue="note" + let style = RenderBlockContent.AsideStyle(asideKind: asideKind) + let expectedJson = """ + {"content":[{"inlineContent":[{"text":"This is a note...","type":"text"}],"type":"paragraph"}],"name":"\(asideKind.displayName)","style":"\(style.rawValue)","type":"aside"} + """ + // Test encoding + try assertJSONEncoding(aside, jsonSortedKeysNoWhitespace: expectedJson) + // Test decoding + try assertJSONRepresentation(aside, expectedJson) } } diff --git a/Tests/SwiftDocCTests/Rendering/RenderBlockContent+AsideStyleTests.swift b/Tests/SwiftDocCTests/Rendering/RenderBlockContent+AsideStyleTests.swift index 4829f1be35..e5e1959c39 100644 --- a/Tests/SwiftDocCTests/Rendering/RenderBlockContent+AsideStyleTests.swift +++ b/Tests/SwiftDocCTests/Rendering/RenderBlockContent+AsideStyleTests.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2022 Apple Inc. and the Swift project authors + Copyright (c) 2022-2025 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information @@ -9,44 +9,80 @@ */ import Foundation +import Markdown import XCTest @testable import SwiftDocC class RenderBlockContent_AsideStyleTests: XCTestCase { + private typealias Aside = RenderBlockContent.Aside private typealias AsideStyle = RenderBlockContent.AsideStyle - + + func testSupportedDocCRenderStyles() { + XCTAssertEqual( + AsideStyle(rawValue: "Note").rawValue, + "note" + ) + XCTAssertEqual( + AsideStyle(rawValue: "Important").rawValue, + "important" + ) + XCTAssertEqual( + AsideStyle(rawValue: "Warning").rawValue, + "warning" + ) + XCTAssertEqual( + AsideStyle(rawValue: "Experiment").rawValue, + "experiment" + ) + XCTAssertEqual( + AsideStyle(rawValue: "Tip").rawValue, + "tip" + ) + XCTAssertEqual( + AsideStyle(rawValue: "Unknown").rawValue, + "note" + ) + } + func testDisplayNameForSpecialRawValue() { XCTAssertEqual( - AsideStyle(rawValue: "nonmutatingvariant").displayName, + Aside(asideKind: .nonMutatingVariant, content: []).name, "Non-Mutating Variant" ) - XCTAssertEqual( - AsideStyle(rawValue: "NonMutatingVariant").displayName, + Aside(asideKind: .init(rawValue: "nonmutatingvariant")!, content: []).name, "Non-Mutating Variant" ) - + + XCTAssertEqual( + Aside(asideKind: .mutatingVariant, content: []).name, + "Mutating Variant" + ) XCTAssertEqual( - AsideStyle(rawValue: "mutatingvariant").displayName, + Aside(asideKind: .init(rawValue: "mutatingvariant")!, content: []).name, "Mutating Variant" ) - + + XCTAssertEqual( + Aside(asideKind: .todo, content: []).name, + "To Do" + ) XCTAssertEqual( - AsideStyle(rawValue: "todo").displayName, + Aside(asideKind: .init(rawValue: "todo")!, content: []).name, "To Do" ) } - + func testDisplayNameForAsideWithExistingUppercasedContent() { XCTAssertEqual( - AsideStyle(rawValue: "Random title").displayName, + Aside(asideKind: .init(rawValue: "Random title")!, content: []).name, "Random title" ) } - + func testDisplayNameForAsideWithLowercasedContent() { XCTAssertEqual( - AsideStyle(rawValue: "random title").displayName, + Aside(asideKind: .init(rawValue: "random title")!, content: []).name, "Random Title" ) } diff --git a/Tests/SwiftDocCTests/Rendering/RenderBlockContent+AsideTests.swift b/Tests/SwiftDocCTests/Rendering/RenderBlockContent+AsideTests.swift new file mode 100644 index 0000000000..5d1d79a4b1 --- /dev/null +++ b/Tests/SwiftDocCTests/Rendering/RenderBlockContent+AsideTests.swift @@ -0,0 +1,459 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2025 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See https://swift.org/LICENSE.txt for license information + See https://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import Foundation +import Markdown +@testable import SwiftDocC +import Testing + +struct RenderBlockContent_AsideTests { + + typealias Aside = RenderBlockContent.Aside + typealias AsideStyle = RenderBlockContent.AsideStyle + + let testBlock: RenderBlockContent = .paragraph( + RenderBlockContent.Paragraph( + inlineContent: [ + RenderInlineContent.text("This is a test paragraph") + ] + ) + ) + + private func testStyle(for name: String) -> AsideStyle { + .init(rawValue: name) + } + + private func decodeAsideRenderBlock(_ json: String, sourceLocation: Testing.SourceLocation = #_sourceLocation) throws -> Aside { + let decodedBlock = try JSONDecoder().decode(RenderBlockContent.self, from: Data(json.utf8)) + var result: Aside? + if case let .aside(aside) = decodedBlock { + result = aside + } + return try #require(result, "Decoded an unexpected type of block.", sourceLocation: sourceLocation) + } + + // Styles supported by DocC Render + @Test(arguments: [ + "Note", "note", + "Tip", "tip", + "Experiment", "experiment", + "Important", "important", + "Warning", "warning" + ]) + func testCreatingSupportedAside(name: String) throws { + + // Creating a style will lowercase the name + let style = testStyle(for: name) + #expect(style.rawValue == name.lowercased()) + + // Aside created with all three attributes. + // All three attributes should be retained. + var aside = Aside( + style: style, + name: name, + content: [testBlock] + ) + #expect(aside.style.rawValue == name.lowercased()) + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside created from the style only. + // The name should use the capitalized style raw value. + aside = Aside(style: style, content: [testBlock]) + #expect(aside.style.rawValue == name.lowercased()) + #expect(aside.name == name.capitalized) + #expect(aside.content == [testBlock]) + + // Aside created from the name only. + // The style should use the lowercased name. + aside = Aside(name: name, content: [testBlock]) + #expect(aside.style.rawValue == name.lowercased()) + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside created from the Swift Markdown aside kind. + // The style will use the lowercased name. + // The name use the capitalized style raw value. + aside = Aside(asideKind: .init(rawValue: name)!, content: [testBlock]) + #expect(aside.style.rawValue == name.lowercased()) + #expect(aside.name == name.capitalized) + #expect(aside.content == [testBlock]) + + // Aside decoded from JSON. + // The style will normally use the lowercased name. + // The name will be retained. + var json = """ + { + "type": "aside", + "style": "\(name.lowercased())", + "name": "\(name)", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == name.lowercased()) + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside decoded from JSON, containing an unexpected capitalized style. + // The style will be lowercased. + // The name will be retained. + json = """ + { + "type": "aside", + "style": "\(name)", + "name": "\(name)", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == name.lowercased()) + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside decoded from JSON - missing name. Render JSON + // may contain a style but not a name. In this case, + // the name should use the capitalized style raw value. + json = """ + { + "type": "aside", + "style": "\(name)", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == name.lowercased()) + #expect(aside.name == name.capitalized) + #expect(aside.content == [testBlock]) + } + + // Custom styles, not supported by DocC Render + @Test(arguments: ["Custom", "unknown", "Special"]) + func testCreatingCustomAside(name: String) throws { + + let style = testStyle(for: name) + + // Aside created from all three attributes. + // The style will always be lowercase "note". + var aside = Aside( + style: style, + name: name, + content: [testBlock] + ) + #expect(aside.style.rawValue == "note") + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside created from the style only. + // The name will always be capitalized "Note". + aside = Aside(style: style, content: [testBlock]) + #expect(aside.style == style) + #expect(aside.name == "Note") + #expect(aside.content == [testBlock]) + + // Aside created from the name only. + // The style will always be "note" + aside = Aside(name: name, content: [testBlock]) + #expect(aside.style.rawValue == "note") + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside created from the Swift Markdown aside kind. + // The style will always be "note" + // The name use the capitalized style raw value. + aside = Aside(asideKind: .init(rawValue: name)!, content: [testBlock]) + #expect(aside.style.rawValue == "note") + #expect(aside.name == name.capitalized) + #expect(aside.content == [testBlock]) + + // Aside decoded from JSON. + // The style will always be "note" - JSON should not exist with unknown styles + // The name use the capitalized style raw value. + var json = """ + { + "type": "aside", + "style": "note", + "name": "\(name)", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == "note") + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside decoded from JSON, containing an unexpected "Note" + // capitalized style. The style will be lowercased. + // The name will be retained. + json = """ + { + "type": "aside", + "style": "Note", + "name": "\(name)", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == "note") // coerced to lowercase + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside decoded from JSON - missing name. Custom styles + // missing a name are coerced to "Note". + json = """ + { + "type": "aside", + "style": "\(name)", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == "note") + #expect(aside.name == "Note") + #expect(aside.content == [testBlock]) + } + + // Asides with different names and styles. + @Test(arguments: [ + "Important": "tip", + "Custom": "warning", + "Special": "note", + ]) + func testCreatingSupportedAside(name: String, styleName: String) throws { + + let style = testStyle(for: styleName) + + // Aside created with all three attributes. + // All three attributes should be retained. + var aside = Aside( + style: style, + name: name, + content: [testBlock] + ) + #expect(aside.style.rawValue == styleName) + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside decoded from JSON. + // The style will normally use the lowercased name. + // The name will be retained. + var json = """ + { + "type": "aside", + "style": "\(styleName)", + "name": "\(name)", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == styleName) + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + + // Aside decoded from JSON, containing an unexpected capitalized style. + // The style will be lowercased. + // The name will be retained. + json = """ + { + "type": "aside", + "style": "\(styleName.capitalized)", + "name": "\(name)", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == styleName) + #expect(aside.name == name) + #expect(aside.content == [testBlock]) + } + + // In Render JSON, the style should always be "note" or one of the supported + // DocC Render styles. Test that invalid, known styles are coerced to "note" + // when decoded. + @Test + func testJSONWithInvalidStyle() throws { + + var json = """ + { + "type": "aside", + "style": "custom", + "name": "Custom", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + var aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == "note") // not "custom" + #expect(aside.name == "Custom") + #expect(aside.content == [testBlock]) + + json = """ + { + "type": "aside", + "style": "custom", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == "note") // not "custom" + #expect(aside.name == "Note") // discard the invalid style in this case + #expect(aside.content == [testBlock]) + } + + // If the name and style do not match, retain both. + @Test + func testJSONDifferentNameAndStyle() throws { + + var json = """ + { + "type": "aside", + "style": "tip", + "name": "Important", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + var aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == "tip") + #expect(aside.name == "Important") + #expect(aside.content == [testBlock]) + + json = """ + { + "type": "aside", + "style": "different", + "name": "Custom", + "content": [ + { + "inlineContent": [ + { + "text": "This is a test paragraph", + "type": "text" + } + ], + "type": "paragraph" + } + ] + } + """ + aside = try decodeAsideRenderBlock(json) + #expect(aside.style.rawValue == "note") // coerced to "note" + #expect(aside.name == "Custom") + #expect(aside.content == [testBlock]) + } +} diff --git a/Tests/SwiftDocCTests/Rendering/RoundTripCoding.swift b/Tests/SwiftDocCTests/Rendering/RoundTripCoding.swift index cb14b48138..fef592f55e 100644 --- a/Tests/SwiftDocCTests/Rendering/RoundTripCoding.swift +++ b/Tests/SwiftDocCTests/Rendering/RoundTripCoding.swift @@ -1,7 +1,7 @@ /* This source file is part of the Swift.org open source project - Copyright (c) 2021 Apple Inc. and the Swift project authors + Copyright (c) 2021-2025 Apple Inc. and the Swift project authors Licensed under Apache License v2.0 with Runtime Library Exception See https://swift.org/LICENSE.txt for license information @@ -60,3 +60,24 @@ func assertJSONRepresentation( XCTAssertEqual(decoded, value, file: (file), line: line) } + +/// Asserts that the given value and its JSON representation are equal, by encoding the given value into JSON. +/// - Parameters: +/// - value: The value to test. +/// - json: The expected JSON, encoded without whitespace and with sorted keys. +/// - Throws: An error if encoding the given value failed. +func assertJSONEncoding( + _ value: Value, + jsonSortedKeysNoWhitespace: String, + file: StaticString = #filePath, + line: UInt = #line +) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let encoded = try encoder.encode(value) + guard let json = String(data: encoded, encoding: .utf8) else { + XCTFail("Invalid encoded data", file: file, line: line) + return + } + XCTAssertEqual(json, jsonSortedKeysNoWhitespace, file: file, line: line) +}