diff --git a/Sources/CliDoc/Modifiers/NoteStyleModifier.swift b/Sources/CliDoc/Modifiers/NoteStyleModifier.swift new file mode 100644 index 0000000..c12ffbd --- /dev/null +++ b/Sources/CliDoc/Modifiers/NoteStyleModifier.swift @@ -0,0 +1,30 @@ +import Rainbow + +public struct NoteStyleConfiguration { + let label: any TextNode + let content: any TextNode +} + +public extension Note { + func noteStyle(_ modifier: S) -> some TextNode { + modifier.render(content: .init(label: label, content: content)) + } +} + +public protocol NoteStyleModifier: NodeModifier where Content == NoteStyleConfiguration {} + +public extension NoteStyleModifier where Self == DefaultNoteStyle { + static var `default`: Self { + DefaultNoteStyle() + } +} + +public struct DefaultNoteStyle: NoteStyleModifier { + + public func render(content: NoteStyleConfiguration) -> some TextNode { + HStack { + content.label.color(.yellow).style(.bold) + content.content + } + } +} diff --git a/Sources/CliDoc/NodeModifier.swift b/Sources/CliDoc/NodeModifier.swift index 7a9b424..9ed763d 100644 --- a/Sources/CliDoc/NodeModifier.swift +++ b/Sources/CliDoc/NodeModifier.swift @@ -6,7 +6,7 @@ public protocol NodeModifier { typealias Body = _Body // swiftlint:enable type_name - associatedtype Content: TextNode + associatedtype Content @TextBuilder func render(content: Content) -> Body diff --git a/Sources/CliDoc/Nodes/Examples.swift b/Sources/CliDoc/Nodes/Examples.swift index 8751c5a..08eefbf 100644 --- a/Sources/CliDoc/Nodes/Examples.swift +++ b/Sources/CliDoc/Nodes/Examples.swift @@ -12,6 +12,7 @@ public struct Examples: TextNode { @usableFromInline let label: Label + @inlinable public init( examples: [Example], @TextBuilder header: () -> Header, @@ -22,19 +23,42 @@ public struct Examples: TextNode { self.label = label() } + @inlinable public var body: some TextNode { - Group(separator: "") { - Group(separator: " ", content: [header.color(.yellow).style(.bold), label, "\n"]) - "\n" - Group( - separator: "\n\n", - content: self.examples.map { example in - Group(separator: "\n") { + VStack(spacing: 2) { + HStack { + header + label + } + VStack(spacing: 2) { + self.examples.map { example in + VStack { CliDoc.Label(example.label.green.bold) ShellCommand { example.example.italic } } } - ) + } } } } + +public extension Examples where Header == String, Label == String { + @inlinable + init( + header: String = "Examples:".yellow.bold, + label: String = "Some common usage examples.", + examples: [Example] + ) { + self.init(examples: examples) { header } label: { label } + } + + @inlinable + init( + header: String = "Examples:".yellow.bold, + label: String = "Some common usage examples.", + examples: Example... + ) { + self.init(header: header, label: label, examples: examples) + } + +} diff --git a/Sources/CliDoc/Nodes/Group.swift b/Sources/CliDoc/Nodes/Group.swift index a063ae6..01f2fe9 100644 --- a/Sources/CliDoc/Nodes/Group.swift +++ b/Sources/CliDoc/Nodes/Group.swift @@ -1,41 +1,16 @@ -public struct Group: TextNode { +public struct Group: TextNode { @usableFromInline - var content: [any TextNode] - - @usableFromInline - var separator: any TextNode + var content: Content @inlinable public init( - separator: any TextNode = "\n", - content: [any TextNode] + @TextBuilder content: () -> Content ) { - self.content = content - self.separator = separator - } - - @inlinable - public init( - separator: any TextNode = "\n", - @TextBuilder content: () -> any TextNode - ) { - // Check if the content is a NodeContainer, typically is when - // using the TextBuilder with more than one text node. - // - // We need to take over the contents, so we can control the separator. - let content = content() - if let many = content as? NodeContainer { - self.content = many.nodes - } else { - // We didn't get a NodeContainer, so fallback to just storing - // the content. - self.content = [content] - } - self.separator = separator + self.content = content() } @inlinable public var body: some TextNode { - content.map { $0.render() }.joined(separator: separator.render()) + content } } diff --git a/Sources/CliDoc/Nodes/HStack.swift b/Sources/CliDoc/Nodes/HStack.swift new file mode 100644 index 0000000..11002d8 --- /dev/null +++ b/Sources/CliDoc/Nodes/HStack.swift @@ -0,0 +1,22 @@ +public struct HStack: TextNode { + + @usableFromInline + let content: [any TextNode] + + @usableFromInline + let separator: any TextNode + + @inlinable + public init( + spacing: Int = 1, + @TextBuilder content: () -> any TextNode + ) { + self.content = array(from: content()) + self.separator = seperator(" ", count: spacing > 0 ? spacing - 1 : 0) + } + + @inlinable + public var body: some TextNode { + content.map { $0.render() }.joined(separator: separator.render()) + } +} diff --git a/Sources/CliDoc/Nodes/Note.swift b/Sources/CliDoc/Nodes/Note.swift index 11eba84..30453a3 100644 --- a/Sources/CliDoc/Nodes/Note.swift +++ b/Sources/CliDoc/Nodes/Note.swift @@ -7,23 +7,21 @@ public struct Note: TextNode { @usableFromInline let content: Content - @usableFromInline - var separator: any TextNode = " " - @inlinable public init( - separator: any TextNode = " ", @TextBuilder _ label: () -> Label, @TextBuilder content: () -> Content ) { - self.separator = separator self.label = label() self.content = content() } @inlinable public var body: some TextNode { - Group(separator: separator, content: [label, content]) + HStack { + label + content + } } } @@ -31,28 +29,49 @@ public extension Note where Label == String { @inlinable init( - separator: any TextNode = " ", - _ label: String = "NOTE:".yellow.bold, + _ label: String = "NOTE:", @TextBuilder content: () -> Content ) { - self.separator = separator self.label = label self.content = content() } static func important( - separator: any TextNode = " ", - _ label: String = "IMPORTANT NOTE:".red.underline, + _ label: String = "IMPORTANT NOTE:", @TextBuilder content: () -> Content ) -> Self { - self.init(separator: separator, label, content: content) + self.init(label, content: content) } static func seeAlso( - separator: any TextNode = " ", - _ label: String = "SEE ALSO:".yellow.bold, + _ label: String = "SEE ALSO:", @TextBuilder content: () -> Content ) -> Self { - self.init(separator: separator, label, content: content) + self.init(label, content: content) + } +} + +public extension Note where Label == String, Content == String { + + @inlinable + init( + _ label: String = "NOTE:", + content: String + ) { + self.init(label) { content } + } + + static func important( + _ label: String = "IMPORTANT NOTE:", + content: String + ) -> Self { + self.init(label, content: content) + } + + static func seeAlso( + _ label: String = "SEE ALSO:", + content: String + ) -> Self { + self.init(label, content: content) } } diff --git a/Sources/CliDoc/Nodes/ShellCommand.swift b/Sources/CliDoc/Nodes/ShellCommand.swift index 1d5af9c..06a454f 100644 --- a/Sources/CliDoc/Nodes/ShellCommand.swift +++ b/Sources/CliDoc/Nodes/ShellCommand.swift @@ -15,7 +15,11 @@ public struct ShellCommand: TextNode { self.content = content() } + @inlinable public var body: some TextNode { - Group(separator: " ", content: [symbol, content]) + HStack { + symbol + content + } } } diff --git a/Sources/CliDoc/Nodes/VStack.swift b/Sources/CliDoc/Nodes/VStack.swift new file mode 100644 index 0000000..f715e3b --- /dev/null +++ b/Sources/CliDoc/Nodes/VStack.swift @@ -0,0 +1,22 @@ +public struct VStack: TextNode { + + @usableFromInline + let content: [any TextNode] + + @usableFromInline + let separator: any TextNode + + @inlinable + public init( + spacing: Int = 1, + @TextBuilder content: () -> any TextNode + ) { + self.content = array(from: content()) + self.separator = seperator("\n", count: spacing > 0 ? spacing - 1 : 0) + } + + @inlinable + public var body: some TextNode { + content.map { $0.render() }.joined(separator: separator.render()) + } +} diff --git a/Sources/CliDoc/TextNode.swift b/Sources/CliDoc/TextNode.swift index 15cdbb5..919d531 100644 --- a/Sources/CliDoc/TextNode.swift +++ b/Sources/CliDoc/TextNode.swift @@ -59,3 +59,15 @@ extension Optional: NodeRepresentable where Wrapped: NodeRepresentable { return node.render() } } + +extension Array: TextNode where Element: TextNode { + public var body: some TextNode { + NodeContainer(nodes: self) + } +} + +extension Array: NodeRepresentable where Element: NodeRepresentable { + public func render() -> String { + map { $0.render() }.joined() + } +} diff --git a/Sources/CliDoc/Utils.swift b/Sources/CliDoc/Utils.swift new file mode 100644 index 0000000..2acd04b --- /dev/null +++ b/Sources/CliDoc/Utils.swift @@ -0,0 +1,21 @@ +@usableFromInline +func array(from node: any TextNode) -> [any TextNode] { + if let container = node as? NodeContainer { + return container.nodes + } else if let array = node as? [any TextNode] { + return array + } else { + return [node] + } +} + +@usableFromInline +func seperator(_ separator: String, count: Int) -> any TextNode { + assert(count >= 0, "Invalid count while creating a separator") + + var output = "" + for _ in 0 ... count { + output += separator + } + return output +} diff --git a/Tests/CliDocTests/CliDoc2Tests.swift b/Tests/CliDocTests/CliDoc2Tests.swift deleted file mode 100644 index f7bb0e6..0000000 --- a/Tests/CliDocTests/CliDoc2Tests.swift +++ /dev/null @@ -1,34 +0,0 @@ -@testable import CliDoc2 -@preconcurrency import Rainbow -import Testing - -let setupRainbow: Bool = { - Rainbow.enabled = true - Rainbow.outputTarget = .console - return true -}() - -@Test -func testGroup() { - #expect(setupRainbow) - let group = Group { - Label { "Foo:" } - "Bar" - "Baz" - Note { "Bang:" } content: { "boom" } - if setupRainbow { - Label("Hello, rainbow").color(.blue) - } else { - Label("No color for you!").color(.red) - } - } - .color(.green) - .style(.italic) - - print(type(of: group)) - print(group.render()) - -// let note = Note { "Bang:" } content: { "boom" } -// print(note.render()) -// print(type(of: note.label)) -} diff --git a/Tests/CliDocTests/CliDocTests.swift b/Tests/CliDocTests/CliDocTests.swift index c6e80b0..91c0e9d 100644 --- a/Tests/CliDocTests/CliDocTests.swift +++ b/Tests/CliDocTests/CliDocTests.swift @@ -2,6 +2,7 @@ @preconcurrency import Rainbow import Testing +// Ensure that rainbow is setup, for test comparisons to work properly. let setupRainbow: Bool = { Rainbow.enabled = true Rainbow.outputTarget = .console @@ -12,31 +13,60 @@ let setupRainbow: Bool = { func testGroup() { #expect(setupRainbow) let group = Group { - Label { "Foo:" } - "Bar" - "Baz" - Note { "Bang:" } content: { "boom" } - if setupRainbow { - Label("Hello, rainbow").color(.blue) - } else { - Label("No color for you!").color(.red) - } + "foo" + "bar" } - .color(.green) - .style(.italic) + #expect(group.render() == "foobar") +} - print(type(of: group)) - print(group.render()) +@Test +func testHStack() { + #expect(setupRainbow) + let stack = HStack { + "foo" + "bar" + } + #expect(stack.render() == "foo bar") +} -// let note = Note { "Bang:" } content: { "boom" } -// print(note.render()) -// print(type(of: note.label)) +@Test +func testVStack() { + #expect(setupRainbow) + let stack = VStack { + "foo" + "bar" + } + #expect(stack.render() == """ + foo + bar + """) +} + +@Test +func testNote() { + #expect(setupRainbow) + let note = Note(content: "Some note.").noteStyle(.default) + let expected = """ + \("NOTE:".yellow.bold) Some note. + """ + #expect(note.render() == expected) } @Test func testExamples() { #expect(setupRainbow) - let examples = Examples(examples: [("First", "ls -lah"), ("Second", "find . -name foo")], header: { "Examples:" }, label: { "Common examples." }) + let examples = Examples( + examples: [("First", "ls -lah"), ("Second", "find . -name foo")] + ) - print(examples.render()) + let expected = """ + \("Examples:".yellow.bold) Some common usage examples. + + \("First".green.bold) + $ \("ls -lah".italic) + + \("Second".green.bold) + $ \("find . -name foo".italic) + """ + #expect(examples.render() == expected) }