Compare commits

22 Commits

Author SHA1 Message Date
bbace73d97 feat: Updates platforms in package.swift
All checks were successful
CI / Run tests. (push) Successful in 1m21s
2024-12-18 09:07:14 -05:00
e524056dc6 feat: Removes git-lfs
All checks were successful
CI / Run tests. (push) Successful in 47s
2024-12-09 09:52:56 -05:00
10ced39b62 feat: Fixes readme in wrong location.
All checks were successful
CI / Run tests. (push) Successful in 56s
2024-12-09 09:37:10 -05:00
875b1980e0 feat: Working on documentation
All checks were successful
CI / Run tests. (push) Successful in 58s
2024-12-09 09:35:17 -05:00
78a632c3e5 feat: Adds more documentation and examples.
All checks were successful
CI / Run tests. (push) Successful in 52s
2024-12-08 18:27:31 -05:00
c6a269f062 feat: Adds stack separators, removes some unused nodes from cli-doc module
All checks were successful
CI / Run tests. (push) Successful in 52s
2024-12-08 10:58:03 -05:00
c977a1c805 feat: Test setup-just action
All checks were successful
CI / Run tests. (push) Successful in 49s
2024-12-07 10:47:36 -05:00
c00d4017ea feat: Test setup-just action
All checks were successful
CI / Run tests. (push) Successful in 50s
2024-12-07 10:43:48 -05:00
f6d86343ba feat: Adds ci
All checks were successful
CI / Run tests. (push) Successful in 2m30s
2024-12-07 10:17:04 -05:00
c377a75e06 feat: Adds ci
Some checks failed
CI / Run tests. (push) Failing after 4s
2024-12-07 10:12:19 -05:00
cfc7c1caab feat: Adds ci
Some checks failed
CI / Run tests. (push) Failing after 19s
2024-12-07 09:54:50 -05:00
c4fa42f2b8 feat: Adds ci
Some checks failed
CI / Run tests. (push) Failing after 13s
2024-12-07 09:51:54 -05:00
6b670ce5c4 feat: Adds ci
Some checks failed
CI / Run tests. (push) Failing after 31s
2024-12-07 09:48:45 -05:00
45ab7ca578 feat: Adds extension to argument parser's command configuration 2024-12-07 09:20:02 -05:00
1a559e0236 feat: Working on documentation 2024-12-06 17:01:11 -05:00
f0873d3b44 feat: Working on documentation 2024-12-06 11:06:10 -05:00
9985b55f88 feat: Adds vertical and horizontal separators, renames modifier protocol. 2024-12-05 22:38:42 -05:00
bef91c5277 feat: Updating text style modifiers 2024-12-05 14:13:47 -05:00
e7bbbec7c2 feat: Moves core functionality into it's own library. 2024-12-05 09:59:30 -05:00
33356d8648 wip 2024-12-05 07:50:55 -05:00
b0b218e047 feat: Adds section and section style, needs tests 2024-12-04 17:03:28 -05:00
2a9b350b26 feat: More styles, renames some items. 2024-12-04 16:15:21 -05:00
60 changed files with 2173 additions and 584 deletions

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
.build/*

20
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,20 @@
---
name: CI
on:
push:
branches: ["main"]
pull_request:
jobs:
test:
name: Run tests.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: https://git.housh.dev/actions/setup-just@v1
- name: Setup QEMU
uses: docker/setup-qemu-action@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Run tests.
run: just test-docker

4
.gitignore vendored
View File

@@ -6,3 +6,7 @@ DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
.nvim/*
docs/*
Examples/*.png
./*.png

1
.prettierignore Normal file
View File

@@ -0,0 +1 @@
**/*.docc

8
Examples/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc

24
Examples/Package.resolved Normal file
View File

@@ -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
}

27
Examples/Package.swift Normal file
View File

@@ -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"]
)
]
)

View File

@@ -0,0 +1,17 @@
import ArgumentParser
import CliDoc
@main
struct Application: ParsableCommand {
static var configuration: CommandConfiguration {
.init(
commandName: "examples",
subcommands: [
SectionCommand.self,
VStackCommand.self,
HStackCommand.self,
GroupCommand.self
]
)
}
}

View File

@@ -0,0 +1,21 @@
import ArgumentParser
import CliDocCore
struct GroupCommand: ParsableCommand {
static let configuration = CommandConfiguration(commandName: "group")
func run() throws {
let group = Group {
"My headline."
"\n"
"Some content".color(.green)
"\n"
"Foo Bar".italic()
}
.color(.blue)
print()
print("\(group.render())")
}
}

View File

@@ -0,0 +1,16 @@
import ArgumentParser
import CliDocCore
struct HStackCommand: ParsableCommand {
static let configuration = CommandConfiguration(commandName: "hstack")
func run() throws {
let note = HStack {
"NOTE:".color(.cyan).bold()
"This is my super cool note.".italic()
}
print()
print(note.render())
}
}

View File

@@ -0,0 +1,31 @@
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 {
content.header.color(.green).bold().underline()
content.content
content.footer.italic()
}
.separator(.newLine(count: 2))
}
}

View File

@@ -0,0 +1,21 @@
import ArgumentParser
import CliDocCore
struct VStackCommand: ParsableCommand {
static let configuration = CommandConfiguration(commandName: "vstack")
func run() throws {
let vstack = VStack {
"Blob Esquire"
.color(.yellow)
.bold()
.underline()
"Blob is a super awesome worker.".italic()
}
print()
print(vstack.render())
}
}

View File

@@ -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.
}

12
Examples/justfile Normal file
View File

@@ -0,0 +1,12 @@
executable_path := "./.build/release/examples"
build:
@swift build -c release
run command="section": build
@{{executable_path}} {{command}}
snapshot command="section" outputDir="${PWD}": build
@freeze --execute "{{executable_path}} {{command}}" \
--output {{outputDir}}/"{{command}}.png" \
--width 500

View File

@@ -1,5 +1,5 @@
{
"originHash" : "ce5e40ef3be5875abe47bbfe26413215dee9b937de7df5293343757c7476d755",
"originHash" : "d5e07b58f04c94c37ba09a0f1c9d09fc24e97c047c48b2c5a4a573fcda7f6c98",
"pins" : [
{
"identity" : "rainbow",
@@ -9,6 +9,33 @@
"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"
}
},
{
"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

View File

@@ -4,22 +4,38 @@ import PackageDescription
let package = Package(
name: "swift-cli-doc",
platforms: [.macOS(.v10_15)],
products: [
.library(name: "CliDocCore", targets: ["CliDocCore"]),
.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"),
.package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0")
],
targets: [
.target(
name: "CliDoc",
name: "CliDocCore",
dependencies: [
.product(name: "Rainbow", package: "Rainbow")
]
),
.testTarget(
name: "CliDocCoreTests",
dependencies: ["CliDocCore"]
),
.target(
name: "CliDoc",
dependencies: [
"CliDocCore",
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Rainbow", package: "Rainbow")
]
),
.testTarget(
name: "CliDocTests",
dependencies: ["CliDoc"]
dependencies: ["CliDocCore", "CliDoc"]
)
]
)

26
README.md Normal file
View File

@@ -0,0 +1,26 @@
# swift-cli-doc
A tool for building rich documentation for command line applications using result builders and
syntax similar to `SwiftUI`.
## Getting Started
Add this as a package dependency to your command line application.
```swift
let package = Package(
name: "my-tool"
...
dependencies: [
.package(url: "https://git.housh.dev/michael/swift-cli-doc", from: "0.1.0")
],
targets: [
.executableTarget(
name: "my-tool",
dependencies: [
.product(name: "CliDoc", package: "swift-cli-doc")
]
)
]
)
```

View File

@@ -0,0 +1,98 @@
import ArgumentParser
public extension CommandConfiguration {
/// Generate a new command configuration, using ``TextNode``'s for the abstract,
/// usage, and discussion parameters.
///
///
init<A: TextNode, U: TextNode, D: TextNode>(
commandName: String? = nil,
abstract: Abstract<A>,
usage: Usage<U>,
discussion: Discussion<D>,
version: String = "",
shouldDisplay: Bool = true,
subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
groupedSubcommands: [CommandGroup] = [],
defaultSubcommand: ParsableCommand.Type? = nil,
helpNames: NameSpecification? = nil,
aliases: [String] = []
) {
self.init(
commandName: commandName,
abstract: abstract.render(),
usage: usage.render(),
discussion: discussion.render(),
version: version,
shouldDisplay: shouldDisplay,
subcommands: ungroupedSubcommands,
groupedSubcommands: groupedSubcommands,
defaultSubcommand: defaultSubcommand,
helpNames: helpNames,
aliases: aliases
)
}
/// Generate a new command configuration, using ``TextNode``'s for the usage, and discussion parameters.
///
///
init<U: TextNode, D: TextNode>(
commandName: String? = nil,
abstract: String = "",
usage: Usage<U>,
discussion: Discussion<D>,
version: String = "",
shouldDisplay: Bool = true,
subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
groupedSubcommands: [CommandGroup] = [],
defaultSubcommand: ParsableCommand.Type? = nil,
helpNames: NameSpecification? = nil,
aliases: [String] = []
) {
self.init(
commandName: commandName,
abstract: abstract,
usage: usage.render(),
discussion: discussion.render(),
version: version,
shouldDisplay: shouldDisplay,
subcommands: ungroupedSubcommands,
groupedSubcommands: groupedSubcommands,
defaultSubcommand: defaultSubcommand,
helpNames: helpNames,
aliases: aliases
)
}
/// Generate a new command configuration, using ``TextNode``'s for the discussion parameter.
///
///
init<D: TextNode>(
commandName: String? = nil,
abstract: String = "",
usage: String? = nil,
discussion: Discussion<D>,
version: String = "",
shouldDisplay: Bool = true,
subcommands ungroupedSubcommands: [ParsableCommand.Type] = [],
groupedSubcommands: [CommandGroup] = [],
defaultSubcommand: ParsableCommand.Type? = nil,
helpNames: NameSpecification? = nil,
aliases: [String] = []
) {
self.init(
commandName: commandName,
abstract: abstract,
usage: usage,
discussion: discussion.render(),
version: version,
shouldDisplay: shouldDisplay,
subcommands: ungroupedSubcommands,
groupedSubcommands: groupedSubcommands,
defaultSubcommand: defaultSubcommand,
helpNames: helpNames,
aliases: aliases
)
}
}

View File

@@ -0,0 +1 @@
@_exported import CliDocCore

View File

@@ -1,24 +0,0 @@
import Rainbow
public extension TextNode {
@inlinable
func color(_ color: NamedColor) -> some TextNode {
modifier(ColorModifier(color: color))
}
}
@usableFromInline
struct ColorModifier<Content: TextNode>: NodeModifier {
@usableFromInline
let color: NamedColor
@usableFromInline
init(color: NamedColor) {
self.color = color
}
@usableFromInline
func render(content: Content) -> some TextNode {
content.render().applyingColor(color)
}
}

View File

@@ -1,35 +0,0 @@
import Rainbow
public extension TextNode {
func labelStyle<C: TextNode>(color: NamedColor? = nil, styles: [Style] = []) -> some TextNode where Self == Label<C> {
modifier(LabelStyle(color: color, styles: styles))
}
func labelStyle<C: TextNode>(color: NamedColor? = nil, styles: Style...) -> some TextNode where Self == Label<C> {
labelStyle(color: color, styles: styles)
}
}
public struct LabelStyle<C: TextNode>: NodeModifier {
@usableFromInline
let color: NamedColor?
@usableFromInline
let styles: [Style]
@usableFromInline
init(color: NamedColor? = nil, styles: [Style] = []) {
self.color = color
self.styles = styles
}
@inlinable
public func render(content: Label<C>) -> some TextNode {
var label: any TextNode = content.content
label = label.style(styles)
if let color {
label = label.color(color)
}
return Label { label.eraseToAnyTextNode() }
}
}

View File

@@ -1,30 +0,0 @@
import Rainbow
public struct NoteStyleConfiguration {
let label: any TextNode
let content: any TextNode
}
public extension Note {
func noteStyle<S: NoteStyleModifier>(_ 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
}
}
}

View File

@@ -1,32 +0,0 @@
import Rainbow
public extension TextNode {
@inlinable
func style(_ styles: Style...) -> some TextNode {
modifier(StyleModifier(styles: styles))
}
@inlinable
func style(_ styles: [Style]) -> some TextNode {
modifier(StyleModifier(styles: styles))
}
}
@usableFromInline
struct StyleModifier<Content: TextNode>: NodeModifier {
@usableFromInline
let styles: [Style]
@usableFromInline
init(styles: [Style]) {
self.styles = styles
}
@usableFromInline
func render(content: Content) -> some TextNode {
styles.reduce(content.render()) {
$0.applyingStyle($1)
}
}
}

View File

@@ -1,68 +0,0 @@
import Rainbow
public protocol NodeModifier {
// swiftlint:disable type_name
associatedtype _Body: TextNode
typealias Body = _Body
// swiftlint:enable type_name
associatedtype Content
@TextBuilder
func render(content: Content) -> Body
}
public extension NodeModifier {
func concat<T: NodeModifier>(_ modifier: T) -> ConcatModifier<Self, T> {
return .init(firstModifier: self, secondModifier: modifier)
}
}
public struct ConcatModifier<M0: NodeModifier, M1: NodeModifier>: NodeModifier where M1.Content == M0.Body {
let firstModifier: M0
let secondModifier: M1
public func render(content: M0.Content) -> some TextNode {
let firstOutput = firstModifier.render(content: content)
return secondModifier.render(content: firstOutput)
}
}
public struct ModifiedNode<Content: TextNode, Modifier: NodeModifier> {
@usableFromInline
let content: Content
@usableFromInline
let modifier: Modifier
@usableFromInline
init(content: Content, modifier: Modifier) {
self.content = content
self.modifier = modifier
}
}
extension ModifiedNode: TextNode where Modifier.Content == Content {
public var body: some TextNode {
modifier.render(content: content)
}
@inlinable
func apply<M: NodeModifier>(_ modifier: M) -> ModifiedNode<Content, ConcatModifier<Modifier, M>> {
return .init(content: content, modifier: self.modifier.concat(modifier))
}
}
extension ModifiedNode: NodeRepresentable where Self: TextNode {
public func render() -> String {
body.render()
}
}
public extension TextNode {
@inlinable
func modifier<M: NodeModifier>(_ modifier: M) -> ModifiedNode<Self, M> {
.init(content: self, modifier: modifier)
}
}

View File

@@ -0,0 +1,14 @@
import CliDocCore
public struct Abstract<Content: TextNode>: TextNode {
@usableFromInline
let content: Content
public init(@TextBuilder content: () -> Content) {
self.content = content()
}
public var body: some TextNode {
content
}
}

View File

@@ -0,0 +1,14 @@
import CliDocCore
public struct Discussion<Content: TextNode>: TextNode {
@usableFromInline
let content: Content
public init(@TextBuilder content: () -> Content) {
self.content = content()
}
public var body: some TextNode {
content
}
}

View File

@@ -0,0 +1,160 @@
import Rainbow
public typealias Example = (label: String, example: String)
public struct ExampleSection<Header: TextNode, Label: TextNode>: TextNode {
@usableFromInline
let configuration: ExampleSectionConfiguration
@inlinable
public init(
examples: [Example],
@TextBuilder title: () -> Header,
@TextBuilder label: () -> Label
) {
self.configuration = .init(
title: title(),
label: label(),
examples: examples
)
}
@inlinable
public init(
_ title: @autoclosure () -> Header,
label: @autoclosure () -> Label,
examples: [Example]
) {
self.init(
examples: examples,
title: title,
label: label
)
}
@inlinable
public var body: some TextNode {
style(.default())
}
}
/// The type-erased configuration of an ``ExampleSection``
public struct ExampleSectionConfiguration {
public let title: any TextNode
public let label: any TextNode
public let examples: [Example]
@usableFromInline
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 {
public let examples: [Example]
@usableFromInline
init(examples: [Example]) {
self.examples = examples
}
}
// MARK: - Style
public protocol ExampleSectionStyle: TextModifier where Content == ExampleSectionConfiguration {}
public protocol ExampleStyle: TextModifier where Content == ExampleConfiguration {}
public extension ExampleSection {
@inlinable
func style<S: ExampleSectionStyle>(_ style: S) -> some TextNode {
style.render(content: configuration)
}
@inlinable
func exampleStyle<S: ExampleStyle>(_ style: S) -> some TextNode {
DefaultExampleSectionStyle(exampleStyle: style).render(content: configuration)
}
}
extension Array where Element == Example {
@inlinable
func exampleStyle<S: ExampleStyle>(_ style: S) -> some TextNode {
style.render(content: .init(examples: self))
}
}
public extension ExampleSectionStyle {
@inlinable
static func `default`<S: ExampleStyle>(exampleStyle: S) -> DefaultExampleSectionStyle<S> {
DefaultExampleSectionStyle(exampleStyle: exampleStyle)
}
}
public extension ExampleSectionStyle where Self == DefaultExampleSectionStyle<DefaultExampleStyle> {
@inlinable
static func `default`() -> DefaultExampleSectionStyle<DefaultExampleStyle> {
DefaultExampleSectionStyle()
}
}
public extension ExampleStyle where Self == DefaultExampleStyle {
static var `default`: Self {
DefaultExampleStyle()
}
}
public struct DefaultExampleSectionStyle<Style: ExampleStyle>: ExampleSectionStyle {
@usableFromInline
let exampleStyle: Style
@inlinable
public init(exampleStyle: Style) {
self.exampleStyle = exampleStyle
}
@inlinable
public func render(content: ExampleSectionConfiguration) -> some TextNode {
Section {
exampleStyle.render(content: .init(examples: content.examples))
} header: {
HStack {
content.title
.color(.yellow)
.bold()
content.label.italic()
}
}
}
}
public extension DefaultExampleSectionStyle where Style == DefaultExampleStyle {
@inlinable
init() {
self.init(exampleStyle: .default)
}
}
public struct DefaultExampleStyle: ExampleStyle {
@inlinable
public func render(content: ExampleConfiguration) -> some TextNode {
VStack {
content.examples.map { example in
VStack {
example.label.color(.green).bold()
ShellCommand(example.example).style(.default)
}
}
}
.separator(.newLine(count: 2))
}
}

View File

@@ -1,64 +0,0 @@
import Rainbow
public struct Examples<Header: TextNode, Label: TextNode>: TextNode {
public typealias Example = (label: String, example: String)
@usableFromInline
let examples: [Example]
@usableFromInline
let header: Header
@usableFromInline
let label: Label
@inlinable
public init(
examples: [Example],
@TextBuilder header: () -> Header,
@TextBuilder label: () -> Label
) {
self.examples = examples
self.header = header()
self.label = label()
}
@inlinable
public var body: some TextNode {
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)
}
}

View File

@@ -1,16 +0,0 @@
public struct Group<Content: TextNode>: TextNode {
@usableFromInline
var content: Content
@inlinable
public init(
@TextBuilder content: () -> Content
) {
self.content = content()
}
@inlinable
public var body: some TextNode {
content
}
}

View File

@@ -1,22 +0,0 @@
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())
}
}

View File

@@ -1,19 +0,0 @@
public struct Label<Content: TextNode>: TextNode {
@usableFromInline
let content: Content
@inlinable
public init(@TextBuilder _ content: () -> Content) {
self.content = content()
}
@inlinable
public init(_ content: Content) {
self.content = content
}
@inlinable
public var body: some TextNode {
content
}
}

View File

@@ -1,77 +0,0 @@
import Rainbow
public struct Note<Label: TextNode, Content: TextNode>: TextNode {
@usableFromInline
let label: Label
@usableFromInline
let content: Content
@inlinable
public init(
@TextBuilder _ label: () -> Label,
@TextBuilder content: () -> Content
) {
self.label = label()
self.content = content()
}
@inlinable
public var body: some TextNode {
HStack {
label
content
}
}
}
public extension Note where Label == String {
@inlinable
init(
_ label: String = "NOTE:",
@TextBuilder content: () -> Content
) {
self.label = label
self.content = content()
}
static func important(
_ label: String = "IMPORTANT NOTE:",
@TextBuilder content: () -> Content
) -> Self {
self.init(label, content: content)
}
static func seeAlso(
_ label: String = "SEE ALSO:",
@TextBuilder content: () -> Content
) -> Self {
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)
}
}

View File

@@ -1,25 +1,99 @@
public struct ShellCommand<Content: TextNode>: TextNode {
import CliDocCore
import Rainbow
/// Represents a shell command text node, with a symbol and the content of
/// the command. Used for displaying example shell commands.
///
///
public struct ShellCommand<Content: TextNode, Symbol: TextNode>: 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
public var body: some TextNode {
style(.default)
}
}
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: @autoclosure () -> String,
symbol: @autoclosure () -> String = "$"
) {
self.init(content: content, symbol: symbol)
}
}
public struct ShellCommandConfiguration {
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<S: ShellCommandStyle>(_ style: S) -> some TextNode {
style.render(content: .init(symbol: symbol, content: content))
}
}
// MARK: - Style
public protocol ShellCommandStyle: TextModifier where Content == ShellCommandConfiguration {}
public extension ShellCommandStyle where Self == DefaultShellCommandStyle {
static var `default`: Self { DefaultShellCommandStyle() }
}
public struct DefaultShellCommandStyle: ShellCommandStyle {
public func render(content: ShellCommandConfiguration) -> some TextNode {
HStack {
symbol
content
content.symbol
content.content.italic()
}
}
}

View File

@@ -0,0 +1,14 @@
import CliDocCore
public struct Usage<Content: TextNode>: TextNode {
@usableFromInline
let content: Content
public init(@TextBuilder content: () -> Content) {
self.content = content()
}
public var body: some TextNode {
content
}
}

View File

@@ -1,22 +0,0 @@
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())
}
}

View File

@@ -1,73 +0,0 @@
public protocol NodeRepresentable {
func render() -> String
}
public protocol TextNode: NodeRepresentable {
// swiftlint:disable type_name
associatedtype _Body: TextNode
typealias Body = _Body
// swiftlint:enable type_name
var body: Body { get }
}
public extension TextNode {
func render() -> String {
body.render()
}
}
extension String: NodeRepresentable {
public func render() -> String {
self
}
}
extension String: TextNode {
public var body: some TextNode {
self
}
}
public struct AnyTextNode: TextNode {
let makeString: () -> String
public init<N: TextNode>(_ node: N) {
self.makeString = node.render
}
public var body: some TextNode { makeString() }
}
public extension TextNode {
func eraseToAnyTextNode() -> AnyTextNode {
AnyTextNode(self)
}
}
extension Optional: TextNode where Wrapped: TextNode {
public var body: some TextNode {
guard let node = self else { return "".eraseToAnyTextNode() }
return node.eraseToAnyTextNode()
}
}
extension Optional: NodeRepresentable where Wrapped: NodeRepresentable {
public func render() -> String {
guard let node = self else { return "" }
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()
}
}

View File

@@ -1,21 +0,0 @@
@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
}

View File

@@ -1,3 +1,6 @@
/// A result builder for creating ``TextNode`` types, similar to how
/// `ViewBuilder` works in `SwiftUI`.
///
@resultBuilder
public enum TextBuilder {
@@ -7,27 +10,27 @@ public enum TextBuilder {
}
@inlinable
public static func buildPartialBlock<N0: TextNode, N1: TextNode>(accumulated: N0, next: N1) -> NodeContainer {
public static func buildPartialBlock<N0: TextNode, N1: TextNode>(accumulated: N0, next: N1) -> _NodeContainer {
.init(nodes: [accumulated, next])
}
@inlinable
public static func buildArray<N: TextNode>(_ components: [N]) -> NodeContainer {
public static func buildArray<N: TextNode>(_ components: [N]) -> _NodeContainer {
.init(nodes: components)
}
@inlinable
public static func buildBlock<N: TextNode>(_ components: N...) -> NodeContainer {
public static func buildBlock<N: TextNode>(_ components: N...) -> _NodeContainer {
.init(nodes: components)
}
@inlinable
public static func buildEither<N: TextNode, N1: TextNode>(first component: N) -> EitherNode<N, N1> {
public static func buildEither<N: TextNode, N1: TextNode>(first component: N) -> _EitherNode<N, N1> {
.first(component)
}
@inlinable
public static func buildEither<N: TextNode, N1: TextNode>(second component: N1) -> EitherNode<N, N1> {
public static func buildEither<N: TextNode, N1: TextNode>(second component: N1) -> _EitherNode<N, N1> {
.second(component)
}
@@ -38,7 +41,8 @@ public enum TextBuilder {
}
public enum EitherNode<N: TextNode, N1: TextNode>: TextNode {
// swiftlint:disable type_name
public enum _EitherNode<N: TextNode, N1: TextNode>: TextNode {
case first(N)
case second(N1)
@@ -50,7 +54,7 @@ public enum EitherNode<N: TextNode, N1: TextNode>: TextNode {
}
}
public struct NodeContainer: TextNode {
public struct _NodeContainer: TextNode {
@usableFromInline
var nodes: [any TextNode]
@@ -58,7 +62,7 @@ public struct NodeContainer: TextNode {
@usableFromInline
init(nodes: [any TextNode]) {
self.nodes = nodes.reduce(into: [any TextNode]()) { array, next in
if let many = next as? NodeContainer {
if let many = next as? _NodeContainer {
array += many.nodes
} else {
array.append(next)
@@ -71,3 +75,5 @@ public struct NodeContainer: TextNode {
nodes.reduce("") { $0 + $1.render() }
}
}
// swiftlint:enable type_name

View File

@@ -0,0 +1,30 @@
# ``CliDocCore``
A framework for writing `cli` documentation in a way similar to `SwiftUI`, where
your types conform to ``TextNode`` and implement a ``TextNode/body`` that
returns a ``TextNode``, generally using the essential types described below.
## Topics
### Essentials
- ``VStack``
- ``HStack``
- ``Section``
- ``Group``
- ``Empty``
- ``AnyTextNode``
### Styling
- ``SectionStyle``
- ``DefaultSectionStyle``
- ``SectionConfiguration``
- ``TextStyleConfiguration``
### Base Protocols
- ``TextNode``
- ``TextNodeRepresentable``
- ``TextStyle``
- ``TextModifier``

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,21 @@
/// A type-erased text node.
public struct AnyTextNode: TextNode {
@usableFromInline
let makeString: () -> String
@inlinable
public init<N: TextNode>(_ node: N) {
self.makeString = node.render
}
@inlinable
public var body: some TextNode { makeString() }
}
public extension TextNode {
@inlinable
func eraseToAnyTextNode() -> AnyTextNode {
AnyTextNode(self)
}
}

View File

@@ -0,0 +1,13 @@
/// An empty text node.
///
/// This gets removed from any output when rendering text nodes.
public struct Empty: TextNode {
@inlinable
public init() {}
@inlinable
public var body: some TextNode {
""
}
}

View File

@@ -0,0 +1,38 @@
/// A group of text nodes.
///
/// This allows you to group content together, which can optionally be
/// styled.
///
/// ### Example:
///
/// ```swift
/// let group = Group {
/// "My headline."
/// "\n"
/// "Some content".color(.green)
/// "\n"
/// "Foo Bar".italic()
/// }
///
/// print(group.render())
/// ```
///
/// ![Group example](group.png)
///
public struct Group<Content: TextNode>: TextNode {
@usableFromInline
var content: Content
@inlinable
public init(
@TextBuilder content: () -> Content
) {
self.content = content()
}
@inlinable
public var body: some TextNode {
content
}
}

View File

@@ -0,0 +1,94 @@
/// A horizontal stack of text nodes.
///
/// ### Example:
///
/// ```swift
/// let note = HStack {
/// "NOTE:".color(.cyan).bold()
/// "This is my super cool note".italic()
/// }
///
/// print(note.render())
/// ```
///
/// ![HStack example](hstack.png)
///
///
public struct HStack: TextNode {
@usableFromInline
let content: [any TextNode]
/// Create a new ``HStack`` with the given text nodes.
///
/// - Parameters:
/// - content: The content of the hstack.
@inlinable
public init(
@TextBuilder content: () -> any TextNode
) {
self.content = array(from: content())
}
@inlinable
public var body: some TextNode {
style(.separator(.space()))
}
}
public extension HStack {
/// Apply the given style to a ``HStack``.
///
/// - Parameters:
/// - style: The style to apply to the ``HStack``.
func style<S: HStackStyle>(_ style: S) -> some TextNode {
style.render(content: .init(content: content))
}
/// Apply the given separator to a ``HStack``.
///
/// - Parameters:
/// - separator: The horizontal separator to use with the ``HStack``.
func separator(_ separator: Separator.Horizontal) -> some TextNode {
style(.separator(separator))
}
}
// MARK: - Style
/// Style a ``HStack`` by creating a type that conforms to ``HStackStyle`` and use the
/// style by calling the ``HStack/style(_:)`` method on your instance.
///
public protocol HStackStyle: TextModifier where Content == _StackConfiguration {}
public extension HStackStyle where Self == HStackSeparatorStyle {
/// Apply the given separator on a ``HStack``.
///
/// - See Also: ``HStack/separator(_:)``
///
/// - Parameters:
/// - separator: The vertical separator to use with the ``HStack``.
static func separator(_ separator: Separator.Horizontal) -> Self {
HStackSeparatorStyle(separator: separator)
}
}
/// Separate items in a ``HStack`` with a given horizontal separator.
///
/// - See Also: ``HStack/separator(_:)``.
///
public struct HStackSeparatorStyle: HStackStyle {
@usableFromInline
let separator: Separator.Horizontal
@usableFromInline
init(separator: Separator.Horizontal) {
self.separator = separator
}
@inlinable
public func render(content: _StackConfiguration) -> some TextNode {
AnySeparatableStackNode(content: content, separator: separator)
}
}

View File

@@ -0,0 +1,148 @@
/// A text node that consists of a label and content.
///
///
public struct LabeledContent<Label: TextNode, Content: TextNode>: TextNode {
@usableFromInline
let label: Label
@usableFromInline
let content: Content
/// Create a new labeled content text node.
///
/// - Parameters:
/// - content: The content portion of the labeled content.
/// - label: The label for the content.
@inlinable
public init(
@TextBuilder _ content: () -> Content,
@TextBuilder label: () -> Label
) {
self.label = label()
self.content = content()
}
@inlinable
public var body: some TextNode {
style(.default)
}
}
public extension LabeledContent {
/// Apply the given style to the labeled content.
///
/// - Parameters:
/// - style: The labeled content style to apply.
@inlinable
func style<S: LabeledContentStyle>(_ style: S) -> some TextNode {
style.render(content: .init(label: label, content: content))
}
}
/// Holds the type-erased label and content of a ``LabeledContent`` text node.
///
/// This is used when creating custom styles for the ``LabeledContent``.
///
public struct LabeledContentConfiguration {
/// The type-erased label text node.
public let label: any TextNode
/// The type-erased content text node.
public let content: any TextNode
@usableFromInline
init(label: any TextNode, content: any TextNode) {
self.label = label
self.content = content
}
}
// MARK: - Style
/// Represents a style for ``LabeledContent``.
///
///
public protocol LabeledContentStyle: TextModifier where Content == LabeledContentConfiguration {}
public extension LabeledContentStyle where Self == HorizontalLabeledContentStyle {
/// The default labeled content style, which places the label
/// and content inline with a space as a separator.
///
static var `default`: Self {
horizontal()
}
/// A horizontal labeled content style, which places the label
/// and content inline with the given separator.
///
/// - Parameters:
/// - separator: The horizontal separator to use.
@inlinable
static func horizontal(separator: Separator.Horizontal = .space()) -> Self {
HorizontalLabeledContentStyle(separator: separator)
}
}
public extension LabeledContentStyle where Self == VerticalLabeledContentStyle {
/// A vertical labeled content style, which places the label
/// and content with the given vertical separator.
///
/// - Parameters:
/// - separator: The vertical separator to use.
@inlinable
static func vertical(separator: Separator.Vertical = .newLine()) -> Self {
VerticalLabeledContentStyle(separator: separator)
}
}
/// A labeled content style which places items inline based on a given
/// horizontal separator.
///
/// - See Also: ``LabeledContentStyle/horizontal(separator:)``
///
public struct HorizontalLabeledContentStyle: LabeledContentStyle {
@usableFromInline
let separator: Separator.Horizontal
@usableFromInline
init(separator: Separator.Horizontal) {
self.separator = separator
}
public func render(content: LabeledContentConfiguration) -> some TextNode {
HStack {
content.label
content.content
}
.separator(separator)
}
}
/// A labeled content style which places items based on a given
/// vertical separator.
///
/// - See Also: ``LabeledContentStyle/vertical(separator:)``
///
public struct VerticalLabeledContentStyle: LabeledContentStyle {
@usableFromInline
let separator: Separator.Vertical
@usableFromInline
init(separator: Separator.Vertical) {
self.separator = separator
}
public func render(content: LabeledContentConfiguration) -> some TextNode {
VStack {
content.label
content.content
}
.separator(separator)
}
}

View File

@@ -0,0 +1,181 @@
/// 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())
/// ```
///
/// _Below is an image of the output from the `mySection.render()` above._
///
/// ![Custom section style image](section.png)
///
public struct Section<Header: TextNode, Content: TextNode, Footer: TextNode>: TextNode {
@usableFromInline
let header: Header
@usableFromInline
let content: Content
@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,
@TextBuilder header: () -> Header,
@TextBuilder footer: () -> Footer
) {
self.header = header()
self.content = content()
self.footer = footer()
}
public var body: some TextNode {
style(.default)
}
}
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,
@TextBuilder header: () -> Header
) {
self.init(content: content, header: header) { 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,
@TextBuilder footer: () -> Footer
) {
self.init(content: content, header: { Empty() }, footer: footer)
}
}
// MARK: - Style
public extension Section {
/// Style a ``Section`` using the given ``SectionStyle``.
///
/// - Parameters:
/// - style: The section style to use.
@inlinable
func style<S: SectionStyle>(_ style: S) -> some TextNode {
style.render(content: .init(header: header, content: content, footer: footer))
}
}
/// Holds the type-erased values of a ``Section``, that can be used to create
/// custom styling for 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
init(header: any TextNode, content: any TextNode, footer: any TextNode) {
self.header = header
self.content = content
self.footer = footer
}
}
/// Used to declare a custom style for a ``Section``. Your custom section style
/// must conform to this protocol.
///
public protocol SectionStyle: TextModifier where Content == SectionConfiguration {}
public extension SectionStyle where 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 {
content.header
content.content
content.footer
}
.style(.separator(separator))
}
}

View File

@@ -0,0 +1,116 @@
/// A namespace for separator types that are used in text nodes.
///
/// These are generally used in the ``HStack``'s and ``VStack``'s to
/// specifiy how the nodes they contain are separated.
///
///
/// **Note:**
///
/// > By default nodes do not contain any separators, unless they are written inline.
/// > However, both ``HStack`` and ``VStack`` allow you to specify the separator to use
/// > to control how the individual nodes they contain are separated.
///
public enum Separator {
/// Represents a horizontal separator that can be used between text nodes, typically inside
/// an ``HStack``
///
/// **Note:**
/// > By default nodes do not contain any separators, unless they are written inline.
///
public enum Horizontal: TextNode {
/// Separate nodes by spaces of the given count.
case space(count: Int = 1)
/// Separate nodes by tabs of the given count.
case tab(count: Int = 1)
/// Separate nodes by the provided string of the given count.
///
/// **Note:**
///
/// > This can allow for non-sensical separators, so should only be used
/// > if the provided horizontal separators do not work for you.
///
case custom(String, count: Int = 1)
@TextBuilder
@inlinable
public var body: some TextNode {
switch self {
case let .tab(count: count):
makeSeperator("\t", count: count)
case let .space(count: count):
makeSeperator(" ", count: count)
case let .custom(string, count: count):
makeSeperator(string, count: count)
}
}
}
/// Represents a vertical separator that can be used between text nodes, typically inside
/// a ``VStack``
///
/// **Note:**
/// > By default nodes do not contain any separators, so if you would
/// > like a blank line separating nodes, then a count of `2` is required.
/// > A count of `1` will place nodes on the next line.
///
public enum Vertical: TextNode {
/// Separate nodes by new line characters of the given count.
///
/// **Note:**
/// > By default nodes do not contain any separtors, so if you would
/// > like a blank line separating nodes, then a count of `2` is required.
/// > A count of `1` will place nodes on the next line.
///
///
/// - Parameters:
/// - count: The count of the new lines to use.
case newLine(count: Int = 1)
/// Separate nodes by the supplied string with the given count.
///
/// **Note:**
///
/// > This can allow for non-sensical separators, so should only be used
/// > if the provided vertical separators do not work for you.
///
/// - Parameters:
/// - count: The count of the new lines to use.
case custom(String, count: Int = 1)
@TextBuilder
@inlinable
public var body: some TextNode {
switch self {
case let .newLine(count: count):
makeSeperator("\n", count: count)
case let .custom(string, count: count):
makeSeperator(string, count: count)
}
}
}
}
// MARK: - Private Helpers.
@usableFromInline
func ensuredCount(_ count: Int) -> Int {
guard count >= 1 else { return 1 }
return count
}
@usableFromInline
func makeSeperator(_ separator: String, count: Int) -> some TextNode {
let count = ensuredCount(count)
assert(count >= 1, "Invalid count while creating a separator")
var output = ""
for _ in 1 ... count {
output += separator
}
return output
}

View File

@@ -0,0 +1,36 @@
// swiftlint:disable type_name
/// Represents the content of an ``HStack`` or a ``VStack``.
///
/// This is an internal convenience type, but needs to remain public
/// for protcol conformances to work properly.
public struct _StackConfiguration {
public let content: [any TextNode]
}
// swiftlint:enable type_name
/// A helper type that removes empty text nodes, and applies a separtor between
/// the array of text nodes.
///
@usableFromInline
struct AnySeparatableStackNode<Separator: TextNode>: TextNode {
@usableFromInline
let content: [any TextNode]
@usableFromInline
let separator: Separator
@usableFromInline
init(content: _StackConfiguration, separator: Separator) {
self.content = content.content
self.separator = separator
}
@usableFromInline
var body: some TextNode {
content.removingEmptys()
.map { $0.render() }
.joined(separator: separator.render())
}
}

View File

@@ -0,0 +1,98 @@
/// A vertical stack of text nodes.
///
/// ### Example:
///
/// ```swift
/// let vStack = VStack {
/// "Blob Esquire"
/// .color(.yellow)
/// .bold()
/// .underline()
///
/// "Blob is a super awesome worker.".italic()
/// }
///
/// print(vStack.render())
/// ```
/// ![VStack rendered output](vstack.png)
///
///
public struct VStack: TextNode {
@usableFromInline
let content: [any TextNode]
/// Create a new ``VStack`` with the given text nodes.
///
/// - Parameters:
/// - content: The content of the vstack.
@inlinable
public init(
@TextBuilder content: () -> any TextNode
) {
self.content = array(from: content())
}
@inlinable
public var body: some TextNode {
style(.separator(.newLine(count: 1)))
}
}
public extension VStack {
/// Apply the given style to a ``VStack``.
///
/// - Parameters:
/// - style: The style to apply to the ``VStack``.
func style<S: VStackStyle>(_ style: S) -> some TextNode {
style.render(content: .init(content: content.removingEmptys()))
}
/// Apply the given separator to a ``VStack``.
///
/// - Parameters:
/// - separator: The vertical separator to use with the ``VStack``.
func separator(_ separator: Separator.Vertical) -> some TextNode {
style(.separator(separator))
}
}
// MARK: - Style
/// Style a ``VStack`` by creating a type that conforms to ``VStackStyle`` and use the
/// style by calling the ``VStack/style(_:)`` method on your instance.
///
public protocol VStackStyle: TextModifier where Content == _StackConfiguration {}
public extension VStackStyle where Self == VStackSeparatorStyle {
/// Apply the given separator on a ``VStack``.
///
/// - See Also: ``VStack/separator(_:)``
///
/// - Parameters:
/// - separator: The vertical separator to use with the ``VStack``.
static func separator(_ separator: Separator.Vertical) -> Self {
VStackSeparatorStyle(separator: separator)
}
}
/// Separate items in a ``VStack`` with a given vertical separator.
///
/// - See Also: ``VStack/separator(_:)``.
///
public struct VStackSeparatorStyle: VStackStyle {
@usableFromInline
let separator: Separator.Vertical
@usableFromInline
init(separator: Separator.Vertical) {
self.separator = separator
}
@inlinable
public func render(content: _StackConfiguration) -> some TextNode {
AnySeparatableStackNode(content: content, separator: separator)
}
}

View File

@@ -0,0 +1,19 @@
/// A type that can modify a text node before it is rendered.
///
/// This allows you to create custom styles for your text-nodes.
///
public protocol TextModifier {
// swiftlint:disable type_name
associatedtype _Body: TextNode
typealias Body = _Body
// swiftlint:enable type_name
associatedtype Content
/// Apply custom styling to the text node.
///
/// - Parameters:
/// - content: The text node to be styled.
@TextBuilder
func render(content: Content) -> Body
}

View File

@@ -0,0 +1,69 @@
/// A type that can produce a string to be used as a documentation
/// text node.
public protocol TextNodeRepresentable {
/// Produces the string output to use as the documentation string.
func render() -> String
}
/// A type that can produce a string to be used as a documentation
/// text node.
public protocol TextNode: TextNodeRepresentable {
// swiftlint:disable type_name
associatedtype _Body: TextNode
typealias Body = _Body
// swiftlint:enable type_name
var body: Body { get }
}
public extension TextNode {
func render() -> String {
body.render()
}
}
// MARK: - String
extension String: TextNodeRepresentable {
public func render() -> String { self }
}
extension String: TextNode {
public var body: some TextNode { self }
}
// MARK: - Optional
extension Optional: TextNode where Wrapped: TextNode {
@TextBuilder
public var body: some TextNode {
switch self {
case let .some(node):
node
case .none:
Empty()
}
}
}
extension Optional: TextNodeRepresentable where Wrapped: TextNodeRepresentable, Wrapped: TextNode {
public func render() -> String {
body.render()
}
}
// MARK: - Array
extension Array: TextNode where Element: TextNode {
public var body: some TextNode {
_NodeContainer(nodes: self)
}
}
extension Array: TextNodeRepresentable where Element: TextNodeRepresentable, Element: TextNode {
public func render() -> String {
body.render()
}
}

View File

@@ -0,0 +1,189 @@
import Rainbow
public extension TextNode {
/// Apply coloring to a text node.
///
/// - Parameters:
/// - color: The color to apply to the text node.
@inlinable
func color(_ color: NamedColor) -> some TextNode {
textStyle(_ColorTextStyle(.foreground(.named(color))))
}
/// Apply coloring to a text node.
///
/// - Parameters:
/// - bit8: The color to apply to the text node.
@inlinable
func color(_ bit8: UInt8) -> some TextNode {
textStyle(_ColorTextStyle(.foreground(.bit8(bit8))))
}
/// Apply coloring to a text node using RGB.
///
/// - Parameters:
/// - red: The red color to apply to the text node.
/// - green: The green color to apply to the text node.
/// - blue: The blue color to apply to the text node.
@inlinable
func color(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> some TextNode {
textStyle(_ColorTextStyle(.foreground(.bit24((red, green, blue)))))
}
/// Apply background coloring to a text node.
///
/// - Parameters:
/// - color: The color to apply to the text node.
@inlinable
func backgroundColor(_ color: NamedBackgroundColor) -> some TextNode {
textStyle(_ColorTextStyle(.background(.named(color))))
}
/// Apply background coloring to a text node.
///
/// - Parameters:
/// - bit8: The color to apply to the text node.
@inlinable
func backgroundColor(_ bit8: UInt8) -> some TextNode {
textStyle(_ColorTextStyle(.background(.bit8(bit8))))
}
/// Apply background coloring to a text node using RGB.
///
/// - Parameters:
/// - red: The red color to apply to the text node.
/// - green: The green color to apply to the text node.
/// - blue: The blue color to apply to the text node.
@inlinable
func backgroundColor(_ red: UInt8, _ green: UInt8, _ blue: UInt8) -> some TextNode {
textStyle(_ColorTextStyle(.background(.bit24((red, green, blue)))))
}
/// Apply styles to a text node.
///
/// - Parameters:
/// - styles: The styles to apply.
@inlinable
func textStyle<S: TextStyle>(_ styles: S...) -> some TextNode {
styles.reduce(render()) { string, style in
style.render(content: .init(string)).render()
}
}
/// Apply a bold text style to a text node.
@inlinable
func bold() -> some TextNode { textStyle(.bold) }
/// Apply a dim text style to a text node.
@inlinable
func dim() -> some TextNode { textStyle(.dim) }
/// Apply an italic text style to a text node.
@inlinable
func italic() -> some TextNode { textStyle(.italic) }
/// Apply an underline text style to a text node.
@inlinable
func underline() -> some TextNode { textStyle(.underline) }
/// Apply a blink text style to a text node.
@inlinable
func blink() -> some TextNode { textStyle(.blink) }
/// Apply a strike-through text style to a text node.
@inlinable
func strikeThrough() -> some TextNode { textStyle(.strikeThrough) }
}
/// A general purpose way of styling a text node.
///
/// This is generally used for applying styling that can work with any text node.
///
/// Most of the time you will want to customize styles for a text node's type instead
/// of using this.
public protocol TextStyle: TextModifier where Content == TextStyleConfiguration {}
/// A type-erased text node that can be used for creating
/// custom styles.
///
/// This is generally used to change the style of text nodes as
/// a whole, such as applying text colors or styling such as `bold`.
///
/// Most of the time you will want to customize styles for a text node's
/// type, instead of using this.
///
public struct TextStyleConfiguration {
public let node: any TextNode
@usableFromInline
init(_ node: any TextNode) {
self.node = node
}
}
public extension TextStyle where Self == _StyledText {
@inlinable
static var bold: Self { .init(.bold) }
@inlinable
static var dim: Self { .init(.dim) }
@inlinable
static var italic: Self { .init(.italic) }
@inlinable
static var underline: Self { .init(.underline) }
@inlinable
static var blink: Self { .init(.blink) }
@inlinable
static var strikeThrough: Self { .init(.strikethrough) }
}
// swiftlint:disable type_name
public struct _ColorTextStyle: TextStyle {
@usableFromInline
enum Style {
case foreground(ColorType)
case background(BackgroundColorType)
}
@usableFromInline
let style: Style
@usableFromInline
init(_ style: Style) {
self.style = style
}
@inlinable
public func render(content: TextStyleConfiguration) -> some TextNode {
switch style {
case let .foreground(color):
return content.node.render().applyingColor(color)
case let .background(color):
return content.node.render().applyingBackgroundColor(color)
}
}
}
public struct _StyledText: TextStyle {
@usableFromInline
let style: Style
@usableFromInline
init(_ style: Style) {
self.style = style
}
@inlinable
public func render(content: TextStyleConfiguration) -> some TextNode {
content.node.render().applyingStyle(style)
}
}
// swiftlint:enable type_name

View File

@@ -0,0 +1,25 @@
@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]
}
}
extension Array where Element == (any TextNode) {
@usableFromInline
func removingEmptys() -> [String] {
compactMap { node in
let string = node.render()
if string == "" {
return nil
}
return string
}
}
}

View File

@@ -0,0 +1,269 @@
@testable import CliDocCore
@preconcurrency import Rainbow
import Testing
@Suite("CliDocCore Tests")
struct CliDocCoreTests {
// Ensure that rainbow is setup, for test comparisons to work properly.
let setupRainbow: Bool = {
Rainbow.enabled = true
Rainbow.outputTarget = .console
return true
}()
@Test
func testGroup() {
#expect(setupRainbow)
let group = Group {
"foo"
"bar"
if setupRainbow {
"baz".color(.blue)
}
}
#expect(group.render() == "foobar\("baz".blue)")
let group2 = Group {
VStack {
"My headline."
"Some text."
}
"\n"
HStack {
"Foo"
"Bar"
}
}
.color(.green)
print(group2.render())
#expect(group2.render() == """
My headline.
Some text.
Foo Bar
""".green)
}
@Test
func testHStack() {
#expect(setupRainbow)
let stack = HStack {
"foo"
"bar"
}
#expect(stack.render() == "foo bar")
let tabStack = HStack {
"foo"
"bar"
}
.separator(.tab())
#expect(tabStack.render() == "foo\tbar")
let customStack = HStack {
"foo"
"bar"
}
.separator(.custom(":blob:"))
#expect(customStack.render() == "foo:blob:bar")
}
@Test
func testVStack() {
#expect(setupRainbow)
let stack = VStack {
"foo"
"bar"
}
#expect(stack.render() == """
foo
bar
""")
let customStack = VStack {
"foo"
"bar"
}
.separator(.custom("\n\t"))
#expect(customStack.render() == """
foo
\tbar
""")
}
@Test
func testOptionalTextNode() {
let someNode = String?.some("string")
#expect(someNode.body.render() == "string")
#expect(someNode.render() == "string")
let noneNode = String?.none
#expect(noneNode.body.render() == "")
#expect(noneNode.render() == "")
}
@Test
func testArrayTextNode() {
let array = ["foo", " ", "bar"]
#expect(array.body.render() == "foo bar")
#expect(array.render() == "foo bar")
}
@Test
func testLabeledContent() {
let horizontal = LabeledContent {
"Content"
} label: {
"Label:".color(.yellow).bold()
}
let expected = """
\("Label:".yellow.bold) Content
"""
#expect(horizontal.render() == expected)
#expect(horizontal.style(.vertical()).render() == """
\("Label:".yellow.bold)
Content
""")
}
@Test(arguments: [
Style.bold, .italic, .dim, .underline, .blink, .strikethrough
])
func testTextStyles(style: Style) {
let node = Group { "foo" }.textStyle(_StyledText(style))
let string = "foo".applyingStyle(style)
#expect(node.render() == string)
}
@Test
func testTextStylesDirectlyOnNode() {
let bold = Group { "foo" }.bold()
let string = "foo".bold
#expect(bold.render() == string)
let dim = Group { "foo" }.dim()
let dimString = "foo".dim
#expect(dim.render() == dimString)
let italic = Group { "foo" }.italic()
let italicString = "foo".italic
#expect(italic.render() == italicString)
let blink = Group { "foo" }.blink()
let blinkString = "foo".blink
#expect(blink.render() == blinkString)
let strikeThrough = Group { "foo" }.strikeThrough()
let strikeThroughString = "foo".applyingStyle(.strikethrough)
#expect(strikeThrough.render() == strikeThroughString)
let underline = Group { "foo" }.underline()
let underlineString = "foo".underline
#expect(underline.render() == underlineString)
}
@Test(arguments: NamedColor.allCases)
func testNamedColors(color: NamedColor) {
let foregroundNode = Group { "foo" }.color(color)
let string = "foo".applyingColor(color)
#expect(foregroundNode.render() == string)
let backgroundNode = Group { "foo" }.backgroundColor(color.toBackgroundColor)
let backgroundString = "foo".applyingBackgroundColor(color.toBackgroundColor)
#expect(backgroundNode.render() == backgroundString)
}
@Test
func testBit8Colors() {
let color: UInt8 = 32
let foregroundNode = Group { "foo" }.color(color)
let string = "foo".applyingColor(.bit8(color))
#expect(foregroundNode.render() == string)
let backgroundNode = Group { "foo" }.backgroundColor(color)
let backgroundString = "foo".applyingBackgroundColor(.bit8(color))
#expect(backgroundNode.render() == backgroundString)
let rgbNode = Group { "foo" }.color(color, color, color)
let rgbString = "foo".applyingColor(.bit24((color, color, color)))
#expect(rgbNode.render() == rgbString)
let rgbBackgroundNode = Group { "foo" }.backgroundColor(color, color, color)
let rgbBackgroundString = "foo".applyingBackgroundColor(.bit24((color, color, color)))
#expect(rgbBackgroundNode.render() == rgbBackgroundString)
}
@Test(
arguments: SectionArg.arguments
)
func testSection(arg: SectionArg) {
#expect(setupRainbow)
printIfNotEqual(arg.section.render(), arg.expected)
#expect(arg.section.render() == arg.expected)
}
struct SectionArg: @unchecked Sendable {
let section: any TextNode
let expected: String
static var arguments: [Self] {
[
.init(
section: Section {
"Content"
} header: {
"Header"
} footer: {
"Footer"
},
expected: """
Header
Content
Footer
"""
),
.init(
section: Section {
"Content"
} footer: {
"Footer"
},
expected: """
Content
Footer
"""
),
.init(
section: Section {
"Content"
} header: {
"Header"
},
expected: """
Header
Content
"""
)
]
}
}
}
@discardableResult
func printIfNotEqual(_ lhs: String, _ rhs: String) -> Bool {
guard lhs == rhs else {
print(lhs)
print(rhs)
return false
}
return true
}

View File

@@ -1,72 +1,98 @@
@testable import CliDoc
@testable import CliDocCore
@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
@Suite("CliDoc tests")
struct CliDocTests {
// Ensure that rainbow is setup, for test comparisons to work properly.
let setupRainbow: Bool = {
Rainbow.enabled = true
Rainbow.outputTarget = .console
return true
}()
@Test
func testExamples() {
#expect(setupRainbow)
let examples = ExampleSection(
"Examples:",
label: "Some common usage examples.",
examples: [("First", "ls -lah"), ("Second", "find . -name foo")]
)
let expected = """
\("Examples:".yellow.bold)\(" ")\("Some common usage examples.".italic)
\("First".green.bold)
$ \("ls -lah".italic)
\("Second".green.bold)
$ \("find . -name foo".italic)
"""
let result = printIfNotEqual(examples.render(), expected)
#expect(result)
}
@Test
func testExamplesWithCustomExampleOnlyStyle() {
#expect(setupRainbow)
let examples = ExampleSection(
"Examples:",
label: "Some common usage examples.",
examples: [("First", "ls -lah"), ("Second", "find . -name foo")]
)
// .exampleStyle(CustomExampleOnlyStyle())
let expected = """
\("Examples:".applyingStyle(.bold).applyingColor(.yellow)) \("Some common usage examples.".italic)
\("First".red)
> \("ls -lah".italic)
\("Second".red)
> \("find . -name foo".italic)
"""
let result = printIfNotEqual(
examples.exampleStyle(CustomExampleOnlyStyle()).render(),
expected
)
#expect(result)
let result2 = printIfNotEqual(
examples.style(.custom).render(),
expected
)
#expect(result2)
}
}
@discardableResult
func printIfNotEqual(_ lhs: String, _ rhs: String) -> Bool {
guard lhs == rhs else {
print(lhs)
print(rhs)
return false
}
return true
}()
}
@Test
func testGroup() {
#expect(setupRainbow)
let group = Group {
"foo"
"bar"
extension ExampleSectionStyle where Self == DefaultExampleSectionStyle<CustomExampleOnlyStyle> {
static var custom: Self {
.default(exampleStyle: CustomExampleOnlyStyle())
}
#expect(group.render() == "foobar")
}
@Test
func testHStack() {
#expect(setupRainbow)
let stack = HStack {
"foo"
"bar"
struct CustomExampleOnlyStyle: ExampleStyle {
func render(content: ExampleConfiguration) -> some TextNode {
VStack {
content.examples.map { example in
VStack {
example.label.red
ShellCommand(symbol: ">") { example.example }
}
}
}
.separator(.newLine(count: 2))
}
#expect(stack.render() == "foo bar")
}
@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")]
)
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)
}

8
docker/Dockerfile.test Normal file
View File

@@ -0,0 +1,8 @@
ARG SWIFT_IMAGE_VERSION="6.0"
FROM swift:${SWIFT_IMAGE_VERSION}
WORKDIR /app
COPY ./Package.* ./
RUN swift package resolve
COPY . .
RUN swift build
CMD ["/bin/bash", "-xc", "swift", "test"]

41
justfile Normal file
View File

@@ -0,0 +1,41 @@
docker_image_name := "clidoc"
[private]
default:
@just --list
clean:
@rm -rf .build
snapshot command outputDir="./Sources/CliDocCore/Documentation.docc/Resources":
@just -f Examples/justfile snapshot {{command}} ".{{outputDir}}"
test-docker: build-docker
@docker run -t --rm {{docker_image_name}}:test swift test
build-docker:
@docker build \
--file docker/Dockerfile.test \
--tag {{docker_image_name}}:test \
.
preview-documentation target="CliDoc":
# using the --enable-experimental-combined-documentation doesn't work in previews currently.
@swift package \
--disable-sandbox \
--allow-writing-to-directory "docs/" \
preview-documentation \
--target {{target}} \
--include-extended-types \
--enable-inherited-docs \
build-documentation:
swift package \
--disable-sandbox \
--allow-writing-to-directory "docs/" \
generate-documentation \
--target CliDoc \
--target CliDocCore \
--output-path "docs/" \
--transform-for-static-hosting \
--enable-experimental-combined-documentation