From f0873d3b441c3acc6701dde84367612100c167a8 Mon Sep 17 00:00:00 2001 From: Michael Housh Date: Fri, 6 Dec 2024 11:06:10 -0500 Subject: [PATCH] feat: Working on documentation --- Examples/.gitignore | 8 ++ Examples/Package.resolved | 24 ++++ Examples/Package.swift | 27 +++++ .../CliDoc-Examples/CliDoc_Examples.swift | 14 +++ .../CliDoc-Examples/SectionCommand.swift | 30 +++++ .../CliDoc_ExamplesTests.swift | 6 + Examples/justfile | 3 + Package.resolved | 20 +++- Package.swift | 3 +- Sources/CliDoc/Nodes/ExampleSection.swift | 30 +++-- Sources/CliDoc/Nodes/Note.swift | 30 +++-- Sources/CliDoc/Nodes/ShellCommand.swift | 64 +++++++--- Sources/CliDocCore/Nodes/Section.swift | 109 ++++++++++++++++-- Sources/CliDocCore/TextStyle.swift | 3 +- Tests/CliDocTests/CliDocTests.swift | 6 +- justfile | 7 ++ 16 files changed, 326 insertions(+), 58 deletions(-) create mode 100644 Examples/.gitignore create mode 100644 Examples/Package.resolved create mode 100644 Examples/Package.swift create mode 100644 Examples/Sources/CliDoc-Examples/CliDoc_Examples.swift create mode 100644 Examples/Sources/CliDoc-Examples/SectionCommand.swift create mode 100644 Examples/Tests/CliDoc-ExamplesTests/CliDoc_ExamplesTests.swift create mode 100644 Examples/justfile create mode 100644 justfile diff --git a/Examples/.gitignore b/Examples/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/Examples/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/Package.resolved b/Examples/Package.resolved new file mode 100644 index 0000000..a34e819 --- /dev/null +++ b/Examples/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "afba34e2c2164a53d5b868cb4ece6f0e542fcaed383f1b226ed9179283d0e060", + "pins" : [ + { + "identity" : "rainbow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Rainbow", + "state" : { + "revision" : "e0dada9cd44e3fa7ec3b867e49a8ddbf543e3df3", + "version" : "4.0.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + } + ], + "version" : 3 +} diff --git a/Examples/Package.swift b/Examples/Package.swift new file mode 100644 index 0000000..8c30315 --- /dev/null +++ b/Examples/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "CliDoc-Examples", + products: [ + .executable(name: "examples", targets: ["CliDoc-Examples"]) + ], + dependencies: [ + .package(path: "../"), + .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0") + ], + targets: [ + .executableTarget( + name: "CliDoc-Examples", + dependencies: [ + .product(name: "CliDoc", package: "swift-cli-doc"), + .product(name: "ArgumentParser", package: "swift-argument-parser") + ] + ), + .testTarget( + name: "CliDoc-ExamplesTests", + dependencies: ["CliDoc-Examples"] + ) + ] +) diff --git a/Examples/Sources/CliDoc-Examples/CliDoc_Examples.swift b/Examples/Sources/CliDoc-Examples/CliDoc_Examples.swift new file mode 100644 index 0000000..1fddc03 --- /dev/null +++ b/Examples/Sources/CliDoc-Examples/CliDoc_Examples.swift @@ -0,0 +1,14 @@ +import ArgumentParser +import CliDoc + +@main +struct Application: ParsableCommand { + static var configuration: CommandConfiguration { + .init( + commandName: "examples", + subcommands: [ + SectionCommand.self + ] + ) + } +} diff --git a/Examples/Sources/CliDoc-Examples/SectionCommand.swift b/Examples/Sources/CliDoc-Examples/SectionCommand.swift new file mode 100644 index 0000000..99f7c47 --- /dev/null +++ b/Examples/Sources/CliDoc-Examples/SectionCommand.swift @@ -0,0 +1,30 @@ +import ArgumentParser +import CliDocCore + +struct SectionCommand: ParsableCommand { + + static var configuration: CommandConfiguration { + .init(commandName: "section") + } + + func run() throws { + let section = Section { + "My super awesome section" + } header: { + "Awesome" + } footer: { + "Note: this is super awesome" + } + print(section.style(MySectionStyle()).render()) + } +} + +struct MySectionStyle: SectionStyle { + func render(content: SectionConfiguration) -> some TextNode { + VStack(separator: .newLine(count: 2)) { + content.header.color(.green).bold().underline() + content.content + content.footer.italic() + } + } +} diff --git a/Examples/Tests/CliDoc-ExamplesTests/CliDoc_ExamplesTests.swift b/Examples/Tests/CliDoc-ExamplesTests/CliDoc_ExamplesTests.swift new file mode 100644 index 0000000..b972886 --- /dev/null +++ b/Examples/Tests/CliDoc-ExamplesTests/CliDoc_ExamplesTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import CliDoc_Examples + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} diff --git a/Examples/justfile b/Examples/justfile new file mode 100644 index 0000000..327c6f4 --- /dev/null +++ b/Examples/justfile @@ -0,0 +1,3 @@ + +run command="section": + swift run examples {{command}} diff --git a/Package.resolved b/Package.resolved index 00198e1..1f848a9 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "ce5e40ef3be5875abe47bbfe26413215dee9b937de7df5293343757c7476d755", + "originHash" : "d1c093149081cbc269ce401633fdb3414e17bc9f7c2290173f3f77bf0537c8a9", "pins" : [ { "identity" : "rainbow", @@ -9,6 +9,24 @@ "revision" : "e0dada9cd44e3fa7ec3b867e49a8ddbf543e3df3", "version" : "4.0.1" } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", + "version" : "1.4.3" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 3c00c1d..2dafdfb 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,8 @@ let package = Package( .library(name: "CliDoc", targets: ["CliDoc"]) ], dependencies: [ - .package(url: "https://github.com/onevcat/Rainbow", from: "4.0.0") + .package(url: "https://github.com/onevcat/Rainbow", from: "4.0.0"), + .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") ], targets: [ .target( diff --git a/Sources/CliDoc/Nodes/ExampleSection.swift b/Sources/CliDoc/Nodes/ExampleSection.swift index 5762e8e..01339f1 100644 --- a/Sources/CliDoc/Nodes/ExampleSection.swift +++ b/Sources/CliDoc/Nodes/ExampleSection.swift @@ -1,7 +1,8 @@ import Rainbow +public typealias Example = (label: String, example: String) + public struct ExampleSection: TextNode { - public typealias Example = (label: String, example: String) @usableFromInline let configuration: ExampleSectionConfiguration @@ -40,29 +41,26 @@ public struct ExampleSection: TextNode { /// The type-erased configuration of an ``ExampleSection`` public struct ExampleSectionConfiguration { - @usableFromInline - let title: any TextNode + public let title: any TextNode + + public let label: any TextNode + + public let examples: [Example] @usableFromInline - let label: any TextNode - - @usableFromInline - let examples: [ExampleSection.Example] - - @usableFromInline - init(title: any TextNode, label: any TextNode, examples: [ExampleSection.Example]) { + init(title: any TextNode, label: any TextNode, examples: [Example]) { self.title = title self.label = label self.examples = examples } } +/// The configuration context for the `examples` of an ``ExampleSection``. public struct ExampleConfiguration { - @usableFromInline - let examples: [ExampleSection.Example] + public let examples: [Example] @usableFromInline - init(examples: [ExampleSection.Example]) { + init(examples: [Example]) { self.examples = examples } } @@ -85,7 +83,7 @@ public extension ExampleSection { } } -extension Array where Element == ExampleSection.Example { +extension Array where Element == Example { @inlinable func exampleStyle(_ style: S) -> some TextNode { style.render(content: .init(examples: self)) @@ -152,8 +150,8 @@ public struct DefaultExampleStyle: ExampleStyle { VStack(separator: .newLine(count: 2)) { content.examples.map { example in VStack { - Label(example.label.green.bold) - ShellCommand { example.example }.style(.default) + example.label.color(.green).bold() + ShellCommand(example.example).style(.default) } } } diff --git a/Sources/CliDoc/Nodes/Note.swift b/Sources/CliDoc/Nodes/Note.swift index f152c98..44f8d0c 100644 --- a/Sources/CliDoc/Nodes/Note.swift +++ b/Sources/CliDoc/Nodes/Note.swift @@ -1,3 +1,4 @@ +import CliDocCore import Rainbow public struct Note: TextNode { @@ -18,7 +19,7 @@ public struct Note: TextNode { @inlinable public var body: some TextNode { - noteStyle(.default) + style(.default) } } @@ -26,11 +27,10 @@ public extension Note where Label == String { @inlinable init( - _ label: String = "NOTE:", + _ label: @autoclosure () -> String = "NOTE:", @TextBuilder content: () -> Content ) { - self.label = label - self.content = content() + self.init(label, content: content) } static func important( @@ -48,6 +48,7 @@ public extension Note where Label == String { } } +// TODO: Remove the important and see also. public extension Note where Label == String, Content == String { @inlinable @@ -74,28 +75,39 @@ public extension Note where Label == String, Content == String { } public struct NoteStyleConfiguration { + @usableFromInline let label: any TextNode + + @usableFromInline let content: any TextNode + + @usableFromInline + init(label: any TextNode, content: any TextNode) { + self.label = label + self.content = content + } } public extension Note { - func noteStyle(_ modifier: S) -> some TextNode { - modifier.render(content: .init(label: label, content: content)) + @inlinable + func style(_ modifier: S) -> some TextNode { + modifier.render(content: NoteStyleConfiguration(label: label, content: content)) } } // MARK: - Style -public protocol NoteStyleModifier: TextModifier where Content == NoteStyleConfiguration {} +public protocol NoteStyle: TextModifier where Content == NoteStyleConfiguration {} -public extension NoteStyleModifier where Self == DefaultNoteStyle { +public extension NoteStyle where Self == DefaultNoteStyle { static var `default`: Self { DefaultNoteStyle() } } -public struct DefaultNoteStyle: NoteStyleModifier { +public struct DefaultNoteStyle: NoteStyle { + @inlinable public func render(content: NoteStyleConfiguration) -> some TextNode { HStack { content.label.color(.yellow).textStyle(.bold) diff --git a/Sources/CliDoc/Nodes/ShellCommand.swift b/Sources/CliDoc/Nodes/ShellCommand.swift index 9748ad8..bc16b5f 100644 --- a/Sources/CliDoc/Nodes/ShellCommand.swift +++ b/Sources/CliDoc/Nodes/ShellCommand.swift @@ -1,20 +1,43 @@ +import CliDocCore import Rainbow -public struct ShellCommand: TextNode { +/// Represents a shell command text node, with a symbol and the content of +/// the command. Used for displaying example shell commands. +/// +/// +public struct ShellCommand: TextNode { @usableFromInline - var symbol: any TextNode + let symbol: Symbol @usableFromInline - var content: Content + let content: Content + /// Create a new shell command with the given content and symbol. + /// + /// - Parameters: + /// - content: The shell command to display. + /// - symbol: The symbol to use in front of the shell command. @inlinable public init( - symbol: any TextNode = "$", + @TextBuilder content: () -> Content, + @TextBuilder symbol: () -> Symbol + ) { + self.symbol = symbol() + self.content = content() + } + + /// Create a new shell command with the given content and symbol. + /// + /// - Parameters: + /// - symbol: The symbol to use in front of the shell command. + /// - content: The shell command to display. + @inlinable + public init( + symbol: @autoclosure () -> Symbol, @TextBuilder content: () -> Content ) { - self.symbol = symbol - self.content = content() + self.init(content: content, symbol: symbol) } @inlinable @@ -23,22 +46,35 @@ public struct ShellCommand: TextNode { } } -public extension ShellCommand where Content == String { +public extension ShellCommand where Content == String, Symbol == String { + /// Create a new shell command with the given content and symbol. + /// + /// - Parameters: + /// - content: The shell command to display. + /// - symbol: The symbol to use in front of the shell command. @inlinable init( - _ content: String, - symbol: any TextNode = "$" + _ content: @autoclosure () -> String, + symbol: @autoclosure () -> String = "$" ) { - self.init(symbol: symbol) { content } + self.init(content: content, symbol: symbol) } } public struct ShellCommandConfiguration { - let symbol: any TextNode - let content: any TextNode + public let symbol: any TextNode + + public let content: any TextNode + + @usableFromInline + init(symbol: any TextNode, content: any TextNode) { + self.symbol = symbol + self.content = content + } } public extension ShellCommand { + @inlinable func style(_ style: S) -> some TextNode { style.render(content: .init(symbol: symbol, content: content)) } @@ -46,7 +82,7 @@ public extension ShellCommand { // MARK: - Style -public protocol ShellCommandStyle: TextModifier where Self.Content == ShellCommandConfiguration {} +public protocol ShellCommandStyle: TextModifier where Content == ShellCommandConfiguration {} public extension ShellCommandStyle where Self == DefaultShellCommandStyle { static var `default`: Self { DefaultShellCommandStyle() } @@ -57,7 +93,7 @@ public struct DefaultShellCommandStyle: ShellCommandStyle { public func render(content: ShellCommandConfiguration) -> some TextNode { HStack { content.symbol - content.content.textStyle(.italic) + content.content.italic() } } } diff --git a/Sources/CliDocCore/Nodes/Section.swift b/Sources/CliDocCore/Nodes/Section.swift index 57113ab..dc9ae82 100644 --- a/Sources/CliDocCore/Nodes/Section.swift +++ b/Sources/CliDocCore/Nodes/Section.swift @@ -1,4 +1,52 @@ -// TODO: Add vertical spacing. +/// A section of text nodes, that can contain a header, content, and footer. +/// +/// This allows nodes to be grouped and styled together. +/// +/// **Example:** +/// +/// ```swift +/// let mySection = Section { +/// "My super awesome section content" +/// } header: { +/// "Awesome" +/// } footer: { +/// "Note: this is super awesome".italic() +/// } +/// ``` +/// +/// **Styling Sections:** +/// +/// You can style a section by creating a custom ``SectionStyle``, which gives you the +/// opportunity to arrange and style the nodes within the section. +/// +/// ```swift +/// struct MySectionStyle: SectionStyle { +/// func render(content: SectionConfiguration) -> some TextNode { +/// VStack(separator: .newLine(count: 2)) { +/// content.header +/// .color(.green) +/// .bold() +/// .underline() +/// content.content +/// content.footer.italic() +/// } +/// } +/// } +/// +/// mySection.style(MySectionStyle()) +/// +/// print(mySection.render()) +/// ``` +/// **Note:** colored output / styling only shows in the terminal. +/// +/// ```bash +/// +/// Awesome +/// +/// My super awesome section +/// +/// Note: this is super awesome +/// ``` public struct Section: TextNode { @usableFromInline @@ -10,6 +58,12 @@ public struct Section: Te @usableFromInline let footer: Footer + /// Create a new section with the given content, header, and footer. + /// + /// - Parameters: + /// - content: The content of the section. + /// - header: The header for the section. + /// - footer: The footer for the section. @inlinable public init( @TextBuilder content: () -> Content, @@ -27,6 +81,11 @@ public struct Section: Te } public extension Section where Footer == Empty { + /// Create a new section with the given content and header, with no footer. + /// + /// - Parameters: + /// - content: The content of the section. + /// - header: The header for the section. @inlinable init( @TextBuilder content: () -> Content, @@ -37,6 +96,11 @@ public extension Section where Footer == Empty { } public extension Section where Header == Empty { + /// Create a new section with the given content and footer, with no header. + /// + /// - Parameters: + /// - content: The content of the section. + /// - footer: The footer for the section. @inlinable init( @TextBuilder content: () -> Content, @@ -46,28 +110,29 @@ public extension Section where Header == Empty { } } -public extension Section where Header == Empty, Footer == Empty { - @inlinable - init( - @TextBuilder content: () -> Content - ) { - self.init(content: content, header: { Empty() }, footer: { Empty() }) - } -} - // MARK: - Style public extension Section { + /// Style a ``Section`` using the given ``SectionStyle``. + /// + /// - Parameters: + /// - style: The section style to use. @inlinable func style(_ style: S) -> some TextNode { style.render(content: .init(header: header, content: content, footer: footer)) } } +/// Holds the type-erased values of a ``Section``, used to style a section. public struct SectionConfiguration { + /// The type-erased header of a section. public let header: any TextNode + + /// The type-erased content of a section. public let content: any TextNode + + /// The type-erased footer of a section. public let footer: any TextNode @usableFromInline @@ -81,13 +146,33 @@ public struct SectionConfiguration { public protocol SectionStyle: TextModifier where Content == SectionConfiguration {} public extension SectionStyle where Self == DefaultSectionStyle { - static var `default`: Self { DefaultSectionStyle() } + + /// Style a section using the default style, separating the content with + /// a new line between the elements. + static var `default`: Self { `default`(separator: .newLine(count: 2)) } + + /// Style a section using the default style, separating the content with + /// given separator between the elements. + /// + /// - Parameters: + /// - separator: The separator to use to separate elements in a section. + static func `default`(separator: Separator.Vertical) -> Self { + DefaultSectionStyle(separator: separator) + } } +/// Represents the default ``SectionStyle``, which arranges the nodes in +/// a ``VStack``, using the separator passed in. +/// +/// - SeeAlso: ``SectionStyle/default(separator:)`` +/// public struct DefaultSectionStyle: SectionStyle { + @usableFromInline + let separator: Separator.Vertical + public func render(content: SectionConfiguration) -> some TextNode { - VStack(separator: .newLine(count: 2)) { + VStack(separator: separator) { content.header content.content content.footer diff --git a/Sources/CliDocCore/TextStyle.swift b/Sources/CliDocCore/TextStyle.swift index 74ffd8a..3f8644a 100644 --- a/Sources/CliDocCore/TextStyle.swift +++ b/Sources/CliDocCore/TextStyle.swift @@ -62,8 +62,7 @@ public extension TextNode { public protocol TextStyle: TextModifier where Content == TextStyleConfiguration {} public struct TextStyleConfiguration { - @usableFromInline - let node: any TextNode + public let node: any TextNode @usableFromInline init(_ node: any TextNode) { diff --git a/Tests/CliDocTests/CliDocTests.swift b/Tests/CliDocTests/CliDocTests.swift index 458b361..278a1f3 100644 --- a/Tests/CliDocTests/CliDocTests.swift +++ b/Tests/CliDocTests/CliDocTests.swift @@ -58,10 +58,10 @@ struct CliDocTests { \("Examples:".applyingStyle(.bold).applyingColor(.yellow)) \("Some common usage examples.".italic) \("First".red) - $ \("ls -lah".italic) + > \("ls -lah".italic) \("Second".red) - $ \("find . -name foo".italic) + > \("find . -name foo".italic) """ let result = printIfNotEqual( examples.exampleStyle(CustomExampleOnlyStyle()).render(), @@ -99,7 +99,7 @@ struct CustomExampleOnlyStyle: ExampleStyle { content.examples.map { example in VStack { example.label.red - ShellCommand { example.example } + ShellCommand(symbol: ">") { example.example } } } } diff --git a/justfile b/justfile new file mode 100644 index 0000000..8f93a6e --- /dev/null +++ b/justfile @@ -0,0 +1,7 @@ + +preview-documentation target="CliDoc": + swift package \ + --disable-sandbox \ + preview-documentation \ + --target {{target}} \ + --include-extended-types